Merge remote-tracking branch 'origin/main'
# Conflicts: # config/database.php
This commit is contained in:
commit
fd0041fdb5
72
app/Console/Commands/ImportPinduoduoSettlementBills.php
Normal file
72
app/Console/Commands/ImportPinduoduoSettlementBills.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\PinduoduoSettlementBillImporter;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ImportPinduoduoSettlementBills extends Command
|
||||
{
|
||||
protected $signature = 'pinduoduo-bills:import-authoritative
|
||||
{path : 聚胜算导出的拼多多完整账单 XLSX}
|
||||
{--from= : 账期开始日期,格式 YYYY-MM-DD}
|
||||
{--to= : 账期结束日期,格式 YYYY-MM-DD}
|
||||
{--expected-count= : 完整账单权威笔数}
|
||||
{--expected-income= : 完整账单权威收入}
|
||||
{--expected-expense= : 完整账单权威支出}
|
||||
{--batch=1000 : 分批写入行数}';
|
||||
|
||||
protected $description = '导入并核验聚胜算拼多多完整账单(保留全部账户类型)';
|
||||
|
||||
public function handle(PinduoduoSettlementBillImporter $importer): int
|
||||
{
|
||||
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
|
||||
if (trim((string) $this->option($option)) === '') {
|
||||
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
(string) $this->option('from'),
|
||||
(string) $this->option('to'),
|
||||
(int) $this->option('expected-count'),
|
||||
(string) $this->option('expected-income'),
|
||||
(string) $this->option('expected-expense'),
|
||||
(int) $this->option('batch'),
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('拼多多完整货款账单导入失败,详细原因已记录到受保护日志。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
|
||||
$result['status'],
|
||||
$result['row_count'],
|
||||
$result['excluded_account_rows'],
|
||||
$result['store_count'],
|
||||
$result['bill_item_count'],
|
||||
$result['income_total'],
|
||||
$result['expense_total'],
|
||||
$result['net_total'],
|
||||
$result['period_start'] . ' 至 ' . $result['period_end'],
|
||||
]]);
|
||||
$this->line('账户类型:' . collect($result['account_type_counts'])
|
||||
->map(fn (int $count, string $account): string => "{$account} {$count}")
|
||||
->implode(';'));
|
||||
$this->line('SHA-256: ' . $result['source_sha256']);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -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' => '供应商应付数据暂时无法读取,请稍后重试。',
|
||||
];
|
||||
|
||||
@ -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(
|
||||
|
||||
196
app/Http/Controllers/Business/SupplierPayableActions.php
Normal file
196
app/Http/Controllers/Business/SupplierPayableActions.php
Normal file
@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableImportTemplate;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableWorkbookImporter;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SupplierPayableActions extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SupplierPayableRepository $supplier,
|
||||
private readonly GoodsReconciliationRepository $goods,
|
||||
private readonly SupplierPayableReportRepository $reports,
|
||||
private readonly SupplierPayableMaintenanceRegistry $registry,
|
||||
private readonly SupplierPayableImportTemplate $template,
|
||||
private readonly SupplierPayableOverrideStore $overrides,
|
||||
private readonly SupplierPayableWorkbookImporter $importer,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
}
|
||||
|
||||
public function definition(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json($this->registry->publicDefinition($this->dataset($request)));
|
||||
}
|
||||
|
||||
public function update(Request $request, string $recordKey): JsonResponse|RedirectResponse
|
||||
{
|
||||
$dataset = $this->dataset($request);
|
||||
$values = $this->registry->normalize($dataset, (array) $request->input('values', []));
|
||||
$result = $this->overrides->save(
|
||||
$dataset,
|
||||
$recordKey,
|
||||
$values,
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['message' => '保存成功。'] + $result)
|
||||
: back()->with('success', '保存成功。');
|
||||
}
|
||||
|
||||
public function import(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]);
|
||||
$result = $this->importer->import(
|
||||
$this->dataset($request),
|
||||
$request->file('import'),
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
$message = "导入完成:新增维护 {$result['created']} 条,更新维护 {$result['updated']} 条。";
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['message' => $message] + $result)
|
||||
: back()->with('success', $message);
|
||||
}
|
||||
|
||||
public function template(Request $request): StreamedResponse
|
||||
{
|
||||
$dataset = $this->dataset($request);
|
||||
|
||||
return $this->template->download($this->title($dataset), $this->registry->fields($dataset));
|
||||
}
|
||||
|
||||
public function export(Request $request): StreamedResponse
|
||||
{
|
||||
$dataset = $this->dataset($request);
|
||||
$fields = $this->registry->fields($dataset);
|
||||
$filters = $this->filters($dataset, $request);
|
||||
$companyId = (int) (company_id() ?: 1);
|
||||
|
||||
return response()->streamDownload(function () use ($dataset, $fields, $filters, $companyId): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
if ($csv === false) {
|
||||
throw new RuntimeException('无法创建导出文件。');
|
||||
}
|
||||
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
fputcsv($csv, array_merge(['记录标识'], array_column($fields, 'label')));
|
||||
|
||||
foreach ($this->allRows($dataset, $filters) as $batch) {
|
||||
foreach ($this->overrides->decorate($dataset, $batch, $companyId) as $row) {
|
||||
$values = [$this->csvText($row['_action_key'] ?? '')];
|
||||
foreach ($fields as $field => $definition) {
|
||||
$values[] = $definition['type'] === 'number' && is_numeric($row[$field] ?? null)
|
||||
? $row[$field]
|
||||
: $this->csvText($row[$field] ?? '');
|
||||
}
|
||||
fputcsv($csv, $values);
|
||||
}
|
||||
}
|
||||
fclose($csv);
|
||||
}, $this->title($dataset) . '_' . now()->format('Ymd_His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return iterable<array<int,array<string,mixed>>> */
|
||||
private function allRows(string $dataset, array $filters): iterable
|
||||
{
|
||||
if ($dataset === 'payment-reconciliation' && ($filters['record_type'] ?? 'all') === 'all') {
|
||||
foreach (['purchase', 'sale'] as $type) {
|
||||
yield from $this->allRows($dataset, array_replace($filters, ['record_type' => $type]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$page = 1;
|
||||
do {
|
||||
$result = $this->page($dataset, $filters, $page, 100);
|
||||
$rows = array_slice((array) ($result['data'] ?? $result['rows'] ?? []), 0, 100);
|
||||
if ($rows !== []) {
|
||||
yield $rows;
|
||||
}
|
||||
$total = (int) ($result['total'] ?? -1);
|
||||
$more = $rows !== [] && ($total < 0 ? count($rows) === 100 : $page * 100 < $total);
|
||||
$page++;
|
||||
} while ($more);
|
||||
}
|
||||
|
||||
private function page(string $dataset, array $filters, int $page, int $limit): array
|
||||
{
|
||||
return match ($dataset) {
|
||||
'payment-reconciliation' => $this->goods->list($filters, $page, $limit),
|
||||
'return-reconciliation' => $this->supplier->returnReconciliations($filters, $page, $limit),
|
||||
'invoice-deductions' => $this->supplier->invoiceDeductions($filters, $page, $limit),
|
||||
'compensation-details' => $this->supplier->compensationDetails($filters, $page, $limit),
|
||||
'advance-details' => $this->supplier->advanceDetails($filters, $page, $limit),
|
||||
'sample-deductions' => $this->supplier->sampleDeductions($filters, $page, $limit),
|
||||
'delivery-unpriced' => $this->supplier->deliveryUnpriced($filters, $page, $limit),
|
||||
'return-unpriced' => $this->supplier->returnUnpriced($filters, $page, $limit),
|
||||
'other-adjustments' => $this->supplier->otherAdjustments($filters, $page, $limit),
|
||||
'dropshipping-express' => $this->supplier->dropshippingExpress($filters, $page, $limit),
|
||||
'board-deductions' => $this->reports->boardDeductions($filters, $page, $limit),
|
||||
'supplier-payables-report' => $this->reports->supplierPayables($filters, $page, $limit),
|
||||
};
|
||||
}
|
||||
|
||||
private function filters(string $dataset, Request $request): array
|
||||
{
|
||||
$allowed = match ($dataset) {
|
||||
'payment-reconciliation' => ['record_type', 'supplier', 'warehouse', 'stock_no', 'purchase_no', 'product_code', 'express_no', 'send_time_from', 'send_time_to', 'create_time_from', 'create_time_to', 'sent_status', 'has_diff'],
|
||||
'return-reconciliation', 'return-unpriced' => ['supplier', 'warehouse', 'sku_id', 'express_no', 'date_from', 'date_to'],
|
||||
'invoice-deductions', 'compensation-details', 'advance-details', 'other-adjustments' => ['supplier', 'month_from', 'month_to'],
|
||||
'sample-deductions' => ['supplier', 'department', 'creator', 'sku_id', 'date_from', 'date_to'],
|
||||
'delivery-unpriced' => ['supplier', 'warehouse', 'purchase', 'barcode', 'express_no', 'date_from', 'date_to'],
|
||||
'dropshipping-express' => ['supplier', 'express_name', 'month_from', 'month_to'],
|
||||
'board-deductions' => ['stock_in_date', 'stock_out_date', 'furniture_factory', 'lamination_factory', 'deduction_type', 'material_name', 'claim_number'],
|
||||
'supplier-payables-report' => ['month', 'supplier', 'checker', 'settlement_method'],
|
||||
};
|
||||
|
||||
return collect($request->only($allowed))
|
||||
->map(static fn (mixed $value): mixed => is_string($value) ? trim($value) : $value)
|
||||
->reject(static fn (mixed $value): bool => $value === '' || $value === null || $value === false)
|
||||
->all();
|
||||
}
|
||||
|
||||
private function dataset(Request $request): string
|
||||
{
|
||||
$dataset = (string) $request->route('dataset');
|
||||
$this->registry->fields($dataset);
|
||||
return $dataset;
|
||||
}
|
||||
|
||||
private function title(string $dataset): string
|
||||
{
|
||||
return [
|
||||
'payment-reconciliation' => '货款对账', 'return-reconciliation' => '退货对账',
|
||||
'invoice-deductions' => '发票扣款', 'compensation-details' => '赔偿明细',
|
||||
'advance-details' => '借支明细', 'sample-deductions' => '样品扣款',
|
||||
'delivery-unpriced' => '送货未定价', 'return-unpriced' => '退货未定价',
|
||||
'other-adjustments' => '其他扣款补款', 'dropshipping-express' => '代发快递',
|
||||
'board-deductions' => '板材扣款', 'supplier-payables-report' => '供应商应付表',
|
||||
][$dataset];
|
||||
}
|
||||
|
||||
private function csvText(mixed $value): string
|
||||
{
|
||||
$value = (string) $value;
|
||||
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
|
||||
}
|
||||
}
|
||||
@ -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' => [
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection(config('database.supplier_payable_maintenance_connection'));
|
||||
|
||||
if (! $schema->hasTable('supplier_payable_overrides')) {
|
||||
$schema->create('supplier_payable_overrides', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedInteger('company_id');
|
||||
$table->string('dataset', 64);
|
||||
$table->string('record_key', 191);
|
||||
$table->json('values');
|
||||
$table->unsignedInteger('created_by')->nullable();
|
||||
$table->unsignedInteger('updated_by')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['company_id', 'dataset', 'record_key'], 'supplier_payable_override_unique');
|
||||
$table->index(['company_id', 'dataset'], 'supplier_payable_override_dataset');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('supplier_payable_override_events')) {
|
||||
$schema->create('supplier_payable_override_events', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedInteger('company_id');
|
||||
$table->string('dataset', 64);
|
||||
$table->string('record_key', 191);
|
||||
$table->string('source', 24);
|
||||
$table->json('before_values')->nullable();
|
||||
$table->json('after_values');
|
||||
$table->unsignedInteger('user_id')->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->index(['company_id', 'dataset', 'record_key'], 'supplier_payable_override_event_lookup');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection(config('database.supplier_payable_maintenance_connection'));
|
||||
$schema->dropIfExists('supplier_payable_override_events');
|
||||
$schema->dropIfExists('supplier_payable_overrides');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = Schema::connection($this->connection());
|
||||
|
||||
if (! $schema->hasTable('pdd_settlement_bill_imports')) {
|
||||
$schema->create('pdd_settlement_bill_imports', function (Blueprint $table): void {
|
||||
$table->uuid('generation')->primary();
|
||||
$table->string('source_filename');
|
||||
$table->char('source_sha256', 64)->unique();
|
||||
$table->date('period_start');
|
||||
$table->date('period_end');
|
||||
$table->string('status', 32)->default('running');
|
||||
$table->unsignedBigInteger('expected_row_count');
|
||||
$table->decimal('expected_income_total', 20, 2);
|
||||
$table->decimal('expected_expense_total', 20, 2);
|
||||
$table->unsignedBigInteger('row_count')->default(0);
|
||||
$table->unsignedBigInteger('excluded_account_rows')->default(0);
|
||||
$table->unsignedInteger('store_count')->default(0);
|
||||
$table->unsignedInteger('bill_item_count')->default(0);
|
||||
$table->json('account_type_counts')->nullable();
|
||||
$table->decimal('income_total', 20, 2)->default(0);
|
||||
$table->decimal('expense_total', 20, 2)->default(0);
|
||||
$table->decimal('net_total', 20, 2)->default(0);
|
||||
$table->string('last_error_code', 120)->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status', 'period_start', 'period_end'], 'pdd_bill_import_period_idx');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('pdd_settlement_bill_staging')) {
|
||||
$schema->create('pdd_settlement_bill_staging', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->uuid('generation');
|
||||
$this->billColumns($table);
|
||||
$table->unique(['generation', 'record_code'], 'pdd_bill_stage_generation_record_uq');
|
||||
$table->index(['generation', 'occurred_at'], 'pdd_bill_stage_generation_time_idx');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('pdd_settlement_bill_rows')) {
|
||||
$schema->create('pdd_settlement_bill_rows', function (Blueprint $table): void {
|
||||
$table->string('record_code', 191)->primary();
|
||||
$this->billColumns($table, false);
|
||||
$table->uuid('source_generation');
|
||||
$table->char('source_sha256', 64);
|
||||
$table->timestamp('imported_at');
|
||||
|
||||
$table->index(['occurred_at', 'record_code'], 'pdd_bill_time_record_idx');
|
||||
$table->index(['store_name', 'occurred_at'], 'pdd_bill_store_time_idx');
|
||||
$table->index(['bill_item', 'occurred_at'], 'pdd_bill_item_time_idx');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = Schema::connection($this->connection());
|
||||
$schema->dropIfExists('pdd_settlement_bill_rows');
|
||||
$schema->dropIfExists('pdd_settlement_bill_staging');
|
||||
$schema->dropIfExists('pdd_settlement_bill_imports');
|
||||
}
|
||||
|
||||
private function billColumns(Blueprint $table, bool $includeRecordCode = true): void
|
||||
{
|
||||
if ($includeRecordCode) {
|
||||
$table->string('record_code', 191);
|
||||
}
|
||||
$table->string('store_name')->nullable();
|
||||
$table->string('custom_remark')->nullable();
|
||||
$table->string('bill_item')->nullable();
|
||||
$table->string('merchant_order_no', 191)->nullable();
|
||||
$table->string('online_order_no', 191)->nullable();
|
||||
$table->string('business_type')->nullable();
|
||||
$table->string('business_description', 1000)->nullable();
|
||||
$table->string('account_name')->nullable();
|
||||
$table->dateTime('occurred_at');
|
||||
$table->decimal('income_amount', 20, 2)->default(0);
|
||||
$table->decimal('expense_amount', 20, 2)->default(0);
|
||||
$table->decimal('signed_amount', 20, 2)->default(0);
|
||||
$table->string('remark', 1000)->nullable();
|
||||
$table->dateTime('source_updated_at')->nullable();
|
||||
$table->string('source_modified_by')->nullable();
|
||||
$table->unsignedBigInteger('source_row_number');
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Http\Controllers;
|
||||
|
||||
use App\Utilities\SidebarPermissions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\AccountBillColumns;
|
||||
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
|
||||
use RuntimeException;
|
||||
|
||||
class PinduoduoAccountBills extends Controller
|
||||
{
|
||||
public function index(Request $request, PinduoduoSettlementBillRepository $repository)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
if ($request->boolean('export')) {
|
||||
return $this->exportResponse($request, $repository);
|
||||
}
|
||||
|
||||
$validated = $request->validate($this->filterRules() + [
|
||||
'sort' => ['nullable', 'in:occurred_at,income,expense,record_code'],
|
||||
'dir' => ['nullable', 'in:asc,desc'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
$filters = collect($validated)->only(array_keys($this->filterRules()))->all();
|
||||
|
||||
try {
|
||||
$data = $repository->paginateAccountBills(
|
||||
$filters,
|
||||
(string) ($validated['sort'] ?? 'occurred_at'),
|
||||
(string) ($validated['dir'] ?? 'desc'),
|
||||
(int) ($validated['page'] ?? 1),
|
||||
(int) ($validated['limit'] ?? 10),
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => '拼多多完整货款账单尚未完成首次导入,当前不会回退到不完整的聚水潭辅助表。',
|
||||
'data' => [],
|
||||
'total' => 0,
|
||||
'current_page' => 1,
|
||||
'last_page' => 1,
|
||||
'per_page' => (int) ($validated['limit'] ?? 10),
|
||||
'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0],
|
||||
'columns' => AccountBillColumns::for('pinduoduo'),
|
||||
], 503);
|
||||
}
|
||||
|
||||
$data['columns'] = AccountBillColumns::for('pinduoduo');
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
public function filterOptions(PinduoduoSettlementBillRepository $repository)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
try {
|
||||
return response()->json($repository->accountBillFilterOptions());
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => '拼多多完整货款账单尚未完成首次导入。',
|
||||
'shops' => [],
|
||||
'bill_items' => [],
|
||||
], 503);
|
||||
}
|
||||
}
|
||||
|
||||
public function export(Request $request, PinduoduoSettlementBillRepository $repository)
|
||||
{
|
||||
$this->authorizeStoreReceivingBills();
|
||||
|
||||
return $this->exportResponse($request, $repository);
|
||||
}
|
||||
|
||||
private function exportResponse(Request $request, PinduoduoSettlementBillRepository $repository)
|
||||
{
|
||||
$validated = $request->validate($this->filterRules() + [
|
||||
'sort' => ['nullable', 'in:occurred_at,income,expense,record_code'],
|
||||
'dir' => ['nullable', 'in:asc,desc'],
|
||||
]);
|
||||
$filters = collect($validated)->only(array_keys($this->filterRules()))->all();
|
||||
$definition = AccountBillColumns::for('pinduoduo');
|
||||
$available = collect($definition['columns'] ?? [])->keyBy('field');
|
||||
$requested = $request->query('columns', []);
|
||||
if (is_string($requested)) {
|
||||
$requested = array_filter(array_map('trim', explode(',', $requested)));
|
||||
}
|
||||
if (! is_array($requested) || count($requested) > 40) {
|
||||
$requested = [];
|
||||
}
|
||||
$requested = collect($requested)
|
||||
->filter(static fn (mixed $value): bool => is_scalar($value))
|
||||
->map(static fn (mixed $value): string => mb_substr(trim((string) $value), 0, 128))
|
||||
->filter()
|
||||
->unique();
|
||||
$columns = $requested->isEmpty()
|
||||
? $available->values()
|
||||
: $requested->map(fn (string $field) => $available->get($field))->filter()->values();
|
||||
abort_if($columns->isEmpty(), 422, '至少选择一个可导出的字段。');
|
||||
|
||||
try {
|
||||
$rows = $repository->exportAccountBills(
|
||||
$filters,
|
||||
(string) ($validated['sort'] ?? 'occurred_at'),
|
||||
(string) ($validated['dir'] ?? 'asc'),
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return response()->json(['message' => '拼多多完整货款账单尚未完成首次导入。'], 503);
|
||||
}
|
||||
|
||||
return response()->streamDownload(function () use ($rows, $columns): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
fputs($csv, "\xEF\xBB\xBF");
|
||||
fputcsv($csv, $columns->pluck('label')->all());
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($csv, $columns->map(
|
||||
fn (array $column): string => $this->exportValue($row, $column)
|
||||
)->all());
|
||||
}
|
||||
fclose($csv);
|
||||
}, 'pinduoduo_account_bills.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,array<int,string>> */
|
||||
private function filterRules(): array
|
||||
{
|
||||
return [
|
||||
'store_name' => ['nullable', 'string', 'max:255'],
|
||||
'bill_item' => ['nullable', 'string', 'max:255'],
|
||||
'start' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
|
||||
'keyword' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function exportValue(array $row, array $column): string
|
||||
{
|
||||
$field = (string) ($column['field'] ?? '');
|
||||
$value = str_starts_with($field, 'payload.')
|
||||
? data_get($row['payload'] ?? [], substr($field, 8))
|
||||
: data_get($row, $field);
|
||||
$text = (string) ($value ?? '');
|
||||
if (preg_match('/^\d{12,}$/', $text) === 1) {
|
||||
return '="' . $text . '"';
|
||||
}
|
||||
|
||||
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
|
||||
}
|
||||
|
||||
private function authorizeStoreReceivingBills(): void
|
||||
{
|
||||
$section = collect(SidebarPermissions::navigationFor(user()))
|
||||
->firstWhere('slug', 'billing-transactions');
|
||||
$page = collect($section['pages'] ?? [])
|
||||
->firstWhere('slug', 'store-receiving-bills');
|
||||
|
||||
abort_if(empty($page), 403, '当前账号没有店铺收款账户账单权限。');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
|
||||
class PinduoduoSettlementBillRepository
|
||||
{
|
||||
private const IMPORT_TABLE = 'pdd_settlement_bill_imports';
|
||||
private const ROW_TABLE = 'pdd_settlement_bill_rows';
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $databases)
|
||||
{
|
||||
$this->connection = $databases->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
);
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
if (! $schema->hasTable(self::IMPORT_TABLE) || ! $schema->hasTable(self::ROW_TABLE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->connection->table(self::IMPORT_TABLE)->where('status', 'succeeded')->exists();
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function storeOptions(): array
|
||||
{
|
||||
return $this->query()->whereNotNull('store_name')->where('store_name', '<>', '')
|
||||
->distinct()->orderBy('store_name')->pluck('store_name')->map('strval')->all();
|
||||
}
|
||||
|
||||
/** @return array{shops:list<string>,bill_items:list<string>} */
|
||||
public function accountBillFilterOptions(): array
|
||||
{
|
||||
$shops = $this->query()->whereNotNull('store_name')->where('store_name', '<>', '')
|
||||
->distinct()->orderBy('store_name')->pluck('store_name')
|
||||
->map(static fn (mixed $value): string => (string) $value)->all();
|
||||
$billItems = $this->query()->whereNotNull('bill_item')->where('bill_item', '<>', '')
|
||||
->distinct()->orderBy('bill_item')->pluck('bill_item')
|
||||
->map(static fn (mixed $value): string => (string) $value)->all();
|
||||
|
||||
return ['shops' => $shops, 'bill_items' => $billItems];
|
||||
}
|
||||
|
||||
/** @return array{rows:list<array<string,mixed>>,total:int} */
|
||||
public function paginate(
|
||||
?string $storeName,
|
||||
?string $fromDate,
|
||||
?string $toDate,
|
||||
?string $flowType,
|
||||
int $page,
|
||||
int $pageSize,
|
||||
): array {
|
||||
$page = max(1, $page);
|
||||
$pageSize = min(max(1, $pageSize), 500);
|
||||
$query = $this->query();
|
||||
$this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType);
|
||||
$total = (clone $query)->count();
|
||||
|
||||
$rows = $query->orderByDesc('occurred_at')->orderByDesc('record_code')
|
||||
->forPage($page, $pageSize)->get()->map(function (object $row): array {
|
||||
$signed = (float) $row->signed_amount;
|
||||
$income = (float) $row->income_amount;
|
||||
$expense = (float) $row->expense_amount;
|
||||
$flowType = $income > 0 ? 1 : ($expense > 0 ? 2 : 0);
|
||||
|
||||
return [
|
||||
'id' => $row->record_code,
|
||||
'shopName' => (string) ($row->store_name ?? ''),
|
||||
'amount' => abs($signed),
|
||||
'balance' => null,
|
||||
'summary' => (string) ($row->bill_item ?? ''),
|
||||
'flowType' => $flowType,
|
||||
'toAdvertiserFundType' => null,
|
||||
'createTime' => (string) $row->occurred_at,
|
||||
'source_primary_key' => (string) ($row->merchant_order_no ?? ''),
|
||||
'source_record_code' => (string) $row->record_code,
|
||||
'source_channel' => 'PinduoduoSettlementBill',
|
||||
];
|
||||
})->all();
|
||||
|
||||
return ['rows' => $rows, 'total' => $total];
|
||||
}
|
||||
|
||||
/** @return array{income_total:float,expense_total:float,cnt:int} */
|
||||
public function summarize(
|
||||
?string $storeName,
|
||||
?string $fromDate,
|
||||
?string $toDate,
|
||||
?string $flowType,
|
||||
): array {
|
||||
$query = $this->query();
|
||||
$this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType);
|
||||
$row = $query->selectRaw(
|
||||
'COALESCE(SUM(income_amount), 0) AS income_total, '
|
||||
. 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS cnt'
|
||||
)->first();
|
||||
|
||||
return [
|
||||
'income_total' => (float) ($row->income_total ?? 0),
|
||||
'expense_total' => (float) ($row->expense_total ?? 0),
|
||||
'cnt' => (int) ($row->cnt ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function paginateAccountBills(
|
||||
array $filters,
|
||||
string $sort,
|
||||
string $direction,
|
||||
int $page,
|
||||
int $perPage,
|
||||
): array {
|
||||
$page = max(1, $page);
|
||||
$perPage = min(max(1, $perPage), 100);
|
||||
$query = $this->query();
|
||||
$this->applyAccountBillFilters($query, $filters);
|
||||
|
||||
$summary = (clone $query)->selectRaw(
|
||||
'COALESCE(SUM(income_amount), 0) AS income_total, '
|
||||
. 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS row_count'
|
||||
)->first();
|
||||
$total = (int) ($summary->row_count ?? 0);
|
||||
$income = (float) ($summary->income_total ?? 0);
|
||||
$expense = (float) ($summary->expense_total ?? 0);
|
||||
|
||||
$sortColumn = $this->accountBillSortColumn($sort);
|
||||
$direction = $direction === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$query->orderBy($sortColumn, $direction);
|
||||
if ($sortColumn !== 'record_code') {
|
||||
$query->orderBy('record_code', $direction);
|
||||
}
|
||||
|
||||
$rows = $query->forPage($page, $perPage)->get()
|
||||
->map(fn (object $row): array => $this->mapAccountBillRow($row))->all();
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => $total,
|
||||
'current_page' => $page,
|
||||
'last_page' => max(1, (int) ceil($total / $perPage)),
|
||||
'per_page' => $perPage,
|
||||
'from' => $total === 0 ? null : (($page - 1) * $perPage) + 1,
|
||||
'to' => $total === 0 ? null : min($page * $perPage, $total),
|
||||
'summary' => [
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'balance' => round($income - $expense, 2),
|
||||
'count' => $total,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return iterable<int,array<string,mixed>> */
|
||||
public function exportAccountBills(array $filters, string $sort, string $direction): iterable
|
||||
{
|
||||
$query = $this->query();
|
||||
$this->applyAccountBillFilters($query, $filters);
|
||||
$sortColumn = $this->accountBillSortColumn($sort);
|
||||
$direction = $direction === 'desc' ? 'desc' : 'asc';
|
||||
$query->orderBy($sortColumn, $direction);
|
||||
if ($sortColumn !== 'record_code') {
|
||||
$query->orderBy('record_code', $direction);
|
||||
}
|
||||
|
||||
return $query->cursor()->map(fn (object $row): array => $this->mapAccountBillRow($row));
|
||||
}
|
||||
|
||||
private function query(): Builder
|
||||
{
|
||||
if (! $this->ready()) {
|
||||
throw new RuntimeException('pinduoduo_authoritative_bills_not_ready');
|
||||
}
|
||||
|
||||
return $this->connection->table(self::ROW_TABLE);
|
||||
}
|
||||
|
||||
private function applyFilters(
|
||||
Builder $query,
|
||||
?string $storeName,
|
||||
?string $fromDate,
|
||||
?string $toDate,
|
||||
?string $flowType,
|
||||
): void {
|
||||
if ($storeName !== null && $storeName !== '') {
|
||||
$query->where('store_name', $storeName);
|
||||
}
|
||||
$from = $this->date($fromDate);
|
||||
if ($from !== null) {
|
||||
$query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00');
|
||||
}
|
||||
$to = $this->date($toDate);
|
||||
if ($to !== null) {
|
||||
$query->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00');
|
||||
}
|
||||
if ($flowType === 'income') {
|
||||
$query->where('income_amount', '>', 0);
|
||||
} elseif ($flowType === 'expense') {
|
||||
$query->where('expense_amount', '>', 0);
|
||||
}
|
||||
}
|
||||
/** @param array<string,mixed> $filters */
|
||||
private function applyAccountBillFilters(Builder $query, array $filters): void
|
||||
{
|
||||
$storeName = trim((string) ($filters['store_name'] ?? ''));
|
||||
if ($storeName !== '') {
|
||||
$query->where('store_name', $storeName);
|
||||
}
|
||||
|
||||
$billItem = trim((string) ($filters['bill_item'] ?? ''));
|
||||
if ($billItem !== '') {
|
||||
$query->where('bill_item', $billItem);
|
||||
}
|
||||
|
||||
$from = $this->date(isset($filters['start']) ? (string) $filters['start'] : null);
|
||||
if ($from !== null) {
|
||||
$query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00');
|
||||
}
|
||||
|
||||
$to = $this->date(isset($filters['end']) ? (string) $filters['end'] : null);
|
||||
if ($to !== null) {
|
||||
$query->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00');
|
||||
}
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function (Builder $nested) use ($keyword): void {
|
||||
$like = '%' . $keyword . '%';
|
||||
$nested->where('record_code', 'like', $like)
|
||||
->orWhere('merchant_order_no', 'like', $like)
|
||||
->orWhere('online_order_no', 'like', $like)
|
||||
->orWhere('remark', 'like', $like)
|
||||
->orWhere('custom_remark', 'like', $like)
|
||||
->orWhere('business_description', 'like', $like)
|
||||
->orWhere('bill_item', 'like', $like);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function accountBillSortColumn(string $sort): string
|
||||
{
|
||||
return [
|
||||
'occurred_at' => 'occurred_at',
|
||||
'income' => 'income_amount',
|
||||
'expense' => 'expense_amount',
|
||||
'record_code' => 'record_code',
|
||||
][$sort] ?? 'occurred_at';
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function mapAccountBillRow(object $row): array
|
||||
{
|
||||
$signedAmount = (float) $row->signed_amount;
|
||||
|
||||
return [
|
||||
'id' => (string) $row->record_code,
|
||||
'record_code' => (string) $row->record_code,
|
||||
'store_name' => (string) ($row->store_name ?? ''),
|
||||
'finance_remark' => (string) ($row->business_description ?? ''),
|
||||
'order_no' => (string) ($row->merchant_order_no ?? ''),
|
||||
'amount' => abs($signedAmount),
|
||||
'custom_remark' => (string) ($row->custom_remark ?? ''),
|
||||
'bill_item' => (string) ($row->bill_item ?? ''),
|
||||
'occurred_at' => (string) $row->occurred_at,
|
||||
'income' => (float) $row->income_amount,
|
||||
'expense' => (float) $row->expense_amount,
|
||||
'balance' => $signedAmount,
|
||||
'remark' => (string) ($row->remark ?? ''),
|
||||
'updated_at' => $row->source_updated_at !== null
|
||||
? (string) $row->source_updated_at
|
||||
: null,
|
||||
'modifier' => (string) ($row->source_modified_by ?? ''),
|
||||
'payload' => [
|
||||
'merchant_order_no' => (string) ($row->merchant_order_no ?? ''),
|
||||
'online_order_no' => (string) ($row->online_order_no ?? ''),
|
||||
'finance_type' => (string) ($row->business_type ?? ''),
|
||||
'business_description' => (string) ($row->business_description ?? ''),
|
||||
'type' => (string) ($row->account_name ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function date(?string $value): ?DateTimeImmutable
|
||||
{
|
||||
if ($value === null || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
|
||||
return null;
|
||||
}
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
|
||||
return $date !== false && $date->format('Y-m-d') === $value ? $date : null;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -0,0 +1,709 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use SimpleXMLElement;
|
||||
use Throwable;
|
||||
use XMLReader;
|
||||
use ZipArchive;
|
||||
|
||||
class PinduoduoSettlementBillImporter
|
||||
{
|
||||
public const ACCOUNT_NAME = '拼多多货款账户';
|
||||
|
||||
private const IMPORT_TABLE = 'pdd_settlement_bill_imports';
|
||||
private const STAGING_TABLE = 'pdd_settlement_bill_staging';
|
||||
private const ROW_TABLE = 'pdd_settlement_bill_rows';
|
||||
|
||||
private const HEADER_ALIASES = [
|
||||
'record_code' => ['记录编码'],
|
||||
'store_name' => ['店铺', '原始店铺'],
|
||||
'custom_remark' => ['自定义备注'],
|
||||
'bill_item' => ['账单项目'],
|
||||
'merchant_order_no' => ['商户订单号'],
|
||||
'online_order_no' => ['线上单号'],
|
||||
'business_type' => ['财务类型', '业务类型'],
|
||||
'business_description' => ['业务描述'],
|
||||
'account_name' => ['类型', '账户'],
|
||||
'occurred_at' => ['发生时间'],
|
||||
'income_amount' => ['收入金额'],
|
||||
'expense_amount' => ['支出金额'],
|
||||
'signed_amount' => ['金额'],
|
||||
'remark' => ['备注'],
|
||||
'source_updated_at' => ['修改时间'],
|
||||
'source_modified_by' => ['修改人'],
|
||||
];
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $databases)
|
||||
{
|
||||
$this->connection = $databases->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status:string,row_count:int,excluded_account_rows:int,store_count:int,bill_item_count:int,account_type_counts:array<string,int>,income_total:string,expense_total:string,net_total:string,period_start:string,period_end:string,source_sha256:string}
|
||||
*/
|
||||
public function import(
|
||||
string $path,
|
||||
string $fromDate,
|
||||
string $toDate,
|
||||
int $expectedCount,
|
||||
string $expectedIncome,
|
||||
string $expectedExpense,
|
||||
int $batchSize = 1000,
|
||||
): array {
|
||||
$path = $this->validatedPath($path);
|
||||
[$from, $to] = $this->period($fromDate, $toDate);
|
||||
$this->assertSchemaReady();
|
||||
if ($expectedCount <= 0) {
|
||||
throw new RuntimeException('权威笔数控制数必须大于零。');
|
||||
}
|
||||
$expectedIncome = $this->controlAmount($expectedIncome, '收入');
|
||||
$expectedExpense = $this->controlAmount($expectedExpense, '支出');
|
||||
|
||||
$batchSize = min(max($batchSize, 100), 5000);
|
||||
$hash = hash_file('sha256', $path);
|
||||
if (! is_string($hash)) {
|
||||
throw new RuntimeException('无法计算拼多多完整账单文件摘要。');
|
||||
}
|
||||
|
||||
$existing = $this->connection->table(self::IMPORT_TABLE)
|
||||
->where('source_sha256', $hash)
|
||||
->first();
|
||||
if ($existing !== null && $existing->status === 'succeeded') {
|
||||
$this->assertSameImportRequest(
|
||||
$existing,
|
||||
$from,
|
||||
$to,
|
||||
$expectedCount,
|
||||
$expectedIncome,
|
||||
$expectedExpense,
|
||||
);
|
||||
if ($this->activeRowCount($hash, $from, $to) === (int) $existing->row_count) {
|
||||
return $this->result((array) $existing, 'unchanged');
|
||||
}
|
||||
}
|
||||
|
||||
$generation = $existing !== null ? (string) $existing->generation : (string) Str::uuid();
|
||||
$now = now();
|
||||
$import = [
|
||||
'generation' => $generation,
|
||||
'source_filename' => mb_substr(basename($path), 0, 255),
|
||||
'source_sha256' => $hash,
|
||||
'period_start' => $from->format('Y-m-d'),
|
||||
'period_end' => $to->format('Y-m-d'),
|
||||
'status' => 'running',
|
||||
'expected_row_count' => $expectedCount,
|
||||
'expected_income_total' => $expectedIncome,
|
||||
'expected_expense_total' => $expectedExpense,
|
||||
'row_count' => 0,
|
||||
'excluded_account_rows' => 0,
|
||||
'store_count' => 0,
|
||||
'bill_item_count' => 0,
|
||||
'account_type_counts' => null,
|
||||
'income_total' => '0.00',
|
||||
'expense_total' => '0.00',
|
||||
'net_total' => '0.00',
|
||||
'completed_at' => null,
|
||||
'last_error_code' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($existing === null) {
|
||||
$this->connection->table(self::IMPORT_TABLE)->insert($import + ['created_at' => $now]);
|
||||
} else {
|
||||
$this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($import);
|
||||
}
|
||||
|
||||
$stats = null;
|
||||
try {
|
||||
$stats = $this->stage($path, $generation, $from, $to, $batchSize);
|
||||
$this->assertControls($stats, $expectedCount, $expectedIncome, $expectedExpense);
|
||||
$this->activate($generation, $hash, $from, $to, $stats, $batchSize);
|
||||
|
||||
return $this->result([
|
||||
'row_count' => $stats['row_count'],
|
||||
'excluded_account_rows' => $stats['excluded_account_rows'],
|
||||
'store_count' => $stats['store_count'],
|
||||
'bill_item_count' => $stats['bill_item_count'],
|
||||
'account_type_counts' => $stats['account_type_counts'],
|
||||
'income_total' => $stats['income_total'],
|
||||
'expense_total' => $stats['expense_total'],
|
||||
'net_total' => $stats['net_total'],
|
||||
'period_start' => $from->format('Y-m-d'),
|
||||
'period_end' => $to->format('Y-m-d'),
|
||||
'source_sha256' => $hash,
|
||||
], 'succeeded');
|
||||
} catch (Throwable $exception) {
|
||||
$this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete();
|
||||
$failure = [
|
||||
'status' => 'failed',
|
||||
'last_error_code' => mb_substr(class_basename($exception), 0, 120),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
if (is_array($stats)) {
|
||||
$failure += [
|
||||
'row_count' => $stats['row_count'],
|
||||
'excluded_account_rows' => $stats['excluded_account_rows'],
|
||||
'store_count' => $stats['store_count'],
|
||||
'bill_item_count' => $stats['bill_item_count'],
|
||||
'account_type_counts' => json_encode($stats['account_type_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'income_total' => $stats['income_total'],
|
||||
'expense_total' => $stats['expense_total'],
|
||||
'net_total' => $stats['net_total'],
|
||||
];
|
||||
}
|
||||
$this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($failure);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function validatedPath(string $path): string
|
||||
{
|
||||
$realPath = realpath($path);
|
||||
if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) {
|
||||
throw new RuntimeException('拼多多完整账单文件不存在或不可读。');
|
||||
}
|
||||
if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') {
|
||||
throw new RuntimeException('聚胜算完整账单导入只支持 XLSX 文件。');
|
||||
}
|
||||
|
||||
return $realPath;
|
||||
}
|
||||
|
||||
/** @return array{0:DateTimeImmutable,1:DateTimeImmutable} */
|
||||
private function period(string $fromDate, string $toDate): array
|
||||
{
|
||||
$from = DateTimeImmutable::createFromFormat('!Y-m-d', $fromDate);
|
||||
$to = DateTimeImmutable::createFromFormat('!Y-m-d', $toDate);
|
||||
if ($from === false || $to === false
|
||||
|| $from->format('Y-m-d') !== $fromDate
|
||||
|| $to->format('Y-m-d') !== $toDate
|
||||
|| $from > $to) {
|
||||
throw new RuntimeException('拼多多账单导入账期无效。');
|
||||
}
|
||||
|
||||
return [$from, $to];
|
||||
}
|
||||
|
||||
private function assertSchemaReady(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
foreach ([self::IMPORT_TABLE, self::STAGING_TABLE, self::ROW_TABLE] as $table) {
|
||||
if (! $schema->hasTable($table)) {
|
||||
throw new RuntimeException('拼多多完整账单表尚未完成迁移:' . $table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{row_count:int,excluded_account_rows:int,store_count:int,bill_item_count:int,account_type_counts:array<string,int>,income_total:string,expense_total:string,net_total:string}
|
||||
*/
|
||||
private function stage(
|
||||
string $path,
|
||||
string $generation,
|
||||
DateTimeImmutable $from,
|
||||
DateTimeImmutable $to,
|
||||
int $batchSize,
|
||||
): array {
|
||||
$archive = new ZipArchive();
|
||||
if ($archive->open($path) !== true) {
|
||||
throw new RuntimeException('无法打开拼多多完整账单 XLSX 文件。');
|
||||
}
|
||||
|
||||
try {
|
||||
$worksheet = $this->firstWorksheet($archive);
|
||||
$sharedStrings = $this->sharedStrings($path, $archive);
|
||||
} finally {
|
||||
$archive->close();
|
||||
}
|
||||
|
||||
$reader = new XMLReader();
|
||||
if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) {
|
||||
throw new RuntimeException('无法读取拼多多完整账单工作表。');
|
||||
}
|
||||
|
||||
$headerMap = null;
|
||||
$batch = [];
|
||||
$rowCount = 0;
|
||||
$excluded = 0;
|
||||
$incomeTotal = '0.00';
|
||||
$expenseTotal = '0.00';
|
||||
$stores = [];
|
||||
$billItems = [];
|
||||
$accountTypeCounts = [];
|
||||
|
||||
try {
|
||||
while ($reader->read()) {
|
||||
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowNumber = (int) ($reader->getAttribute('r') ?: 0);
|
||||
$cells = $this->rowCells($reader->readOuterXml(), $sharedStrings);
|
||||
if ($headerMap === null) {
|
||||
$headerMap = $this->headerMap($cells);
|
||||
continue;
|
||||
}
|
||||
|
||||
$values = [];
|
||||
foreach ($headerMap as $header => $column) {
|
||||
$values[$header] = trim((string) ($cells[$column] ?? ''));
|
||||
}
|
||||
if (implode('', $values) === '') {
|
||||
continue;
|
||||
}
|
||||
$recordCode = $this->required($values, 'record_code', $rowNumber, '记录编码');
|
||||
$accountName = $this->required($values, 'account_name', $rowNumber, '账户类型');
|
||||
$storeName = $this->nullable($values['store_name']);
|
||||
$billItem = $this->nullable($values['bill_item']);
|
||||
$occurredAt = $this->dateTime($values['occurred_at'], $rowNumber, '发生时间');
|
||||
if ($occurredAt < $from->format('Y-m-d') . ' 00:00:00'
|
||||
|| $occurredAt >= $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') {
|
||||
throw new RuntimeException("第 {$rowNumber} 行发生时间超出指定账期。");
|
||||
}
|
||||
|
||||
$income = $this->money($values['income_amount'], $rowNumber, '收入金额', false);
|
||||
$expense = $this->money($values['expense_amount'], $rowNumber, '支出金额', true);
|
||||
$signed = $this->money($values['signed_amount'], $rowNumber, '金额', false, true);
|
||||
$calculated = bcsub($income, $expense, 2);
|
||||
if (bccomp($calculated, $signed, 2) !== 0) {
|
||||
throw new RuntimeException("第 {$rowNumber} 行收入、支出与金额不一致。");
|
||||
}
|
||||
|
||||
$batch[] = [
|
||||
'generation' => $generation,
|
||||
'record_code' => mb_substr($recordCode, 0, 191),
|
||||
'store_name' => $storeName,
|
||||
'custom_remark' => $this->nullable($values['custom_remark']),
|
||||
'bill_item' => $billItem,
|
||||
'merchant_order_no' => $this->nullable($values['merchant_order_no'], 191),
|
||||
'online_order_no' => $this->nullable($values['online_order_no'], 191),
|
||||
'business_type' => $this->nullable($values['business_type']),
|
||||
'business_description' => $this->nullable($values['business_description'], 1000),
|
||||
// Keep the account type from the authoritative export. The
|
||||
// complete Jushengsuan file contains both wallet and marketing
|
||||
// account rows, and the finance page must retain both.
|
||||
'account_name' => mb_substr($accountName, 0, 255),
|
||||
'occurred_at' => $occurredAt,
|
||||
'income_amount' => $income,
|
||||
'expense_amount' => $expense,
|
||||
'signed_amount' => $signed,
|
||||
'remark' => $this->nullable($values['remark'], 1000),
|
||||
'source_updated_at' => $this->optionalDateTime($values['source_updated_at'], $rowNumber, '修改时间'),
|
||||
'source_modified_by' => $this->nullable($values['source_modified_by']),
|
||||
'source_row_number' => $rowNumber,
|
||||
];
|
||||
$rowCount++;
|
||||
if ($storeName !== null) {
|
||||
$stores[$storeName] = true;
|
||||
}
|
||||
if ($billItem !== null) {
|
||||
$billItems[$billItem] = true;
|
||||
}
|
||||
$accountTypeCounts[$accountName] = ($accountTypeCounts[$accountName] ?? 0) + 1;
|
||||
$incomeTotal = bcadd($incomeTotal, $income, 2);
|
||||
$expenseTotal = bcadd($expenseTotal, $expense, 2);
|
||||
|
||||
if (count($batch) >= $batchSize) {
|
||||
$this->connection->table(self::STAGING_TABLE)->insert($batch);
|
||||
$batch = [];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
|
||||
if ($headerMap === null) {
|
||||
throw new RuntimeException('未找到聚胜算拼多多账单标准表头。');
|
||||
}
|
||||
if ($batch !== []) {
|
||||
$this->connection->table(self::STAGING_TABLE)->insert($batch);
|
||||
}
|
||||
if ($rowCount === 0) {
|
||||
throw new RuntimeException('文件中没有拼多多货款账户数据。');
|
||||
}
|
||||
|
||||
return [
|
||||
'row_count' => $rowCount,
|
||||
'excluded_account_rows' => $excluded,
|
||||
'store_count' => count($stores),
|
||||
'bill_item_count' => count($billItems),
|
||||
'account_type_counts' => $accountTypeCounts,
|
||||
'income_total' => $incomeTotal,
|
||||
'expense_total' => $expenseTotal,
|
||||
'net_total' => bcsub($incomeTotal, $expenseTotal, 2),
|
||||
];
|
||||
}
|
||||
|
||||
private function assertControls(array $stats, int $count, string $income, string $expense): void
|
||||
{
|
||||
if ($stats['row_count'] !== $count
|
||||
|| bccomp($stats['income_total'], $income, 2) !== 0
|
||||
|| bccomp($stats['expense_total'], $expense, 2) !== 0) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'权威控制数不一致:实际 %d 笔,收入 %s,支出 %s;期望 %d 笔,收入 %s,支出 %s。',
|
||||
$stats['row_count'],
|
||||
$stats['income_total'],
|
||||
$stats['expense_total'],
|
||||
$count,
|
||||
$income,
|
||||
$expense,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function assertSameImportRequest(
|
||||
object $existing,
|
||||
DateTimeImmutable $from,
|
||||
DateTimeImmutable $to,
|
||||
int $expectedCount,
|
||||
string $expectedIncome,
|
||||
string $expectedExpense,
|
||||
): void {
|
||||
if ((string) $existing->period_start !== $from->format('Y-m-d')
|
||||
|| (string) $existing->period_end !== $to->format('Y-m-d')
|
||||
|| (int) $existing->expected_row_count !== $expectedCount
|
||||
|| bccomp((string) $existing->expected_income_total, $expectedIncome, 2) !== 0
|
||||
|| bccomp((string) $existing->expected_expense_total, $expectedExpense, 2) !== 0) {
|
||||
throw new RuntimeException('该文件已有成功导入记录,但本次账期或权威控制数与历史记录不一致。');
|
||||
}
|
||||
}
|
||||
|
||||
private function activeRowCount(
|
||||
string $hash,
|
||||
DateTimeImmutable $from,
|
||||
DateTimeImmutable $to,
|
||||
): int {
|
||||
return $this->connection->table(self::ROW_TABLE)
|
||||
->where('source_sha256', $hash)
|
||||
->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00')
|
||||
->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00')
|
||||
->count();
|
||||
}
|
||||
|
||||
private function activate(
|
||||
string $generation,
|
||||
string $hash,
|
||||
DateTimeImmutable $from,
|
||||
DateTimeImmutable $to,
|
||||
array $stats,
|
||||
int $batchSize,
|
||||
): void {
|
||||
$now = now();
|
||||
$columns = [
|
||||
'store_name', 'custom_remark', 'bill_item', 'merchant_order_no', 'online_order_no',
|
||||
'business_type', 'business_description', 'account_name', 'occurred_at', 'income_amount',
|
||||
'expense_amount', 'signed_amount', 'remark', 'source_updated_at', 'source_modified_by',
|
||||
'source_row_number', 'source_generation', 'source_sha256', 'imported_at',
|
||||
];
|
||||
|
||||
$this->connection->transaction(function () use (
|
||||
$generation, $hash, $from, $to, $stats, $batchSize, $columns, $now
|
||||
): void {
|
||||
$this->connection->table(self::ROW_TABLE)
|
||||
->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00')
|
||||
->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00')
|
||||
->delete();
|
||||
|
||||
$this->connection->table(self::STAGING_TABLE)
|
||||
->where('generation', $generation)
|
||||
->orderBy('id')
|
||||
->chunkById($batchSize, function ($rows) use ($generation, $hash, $columns, $now): void {
|
||||
$payload = [];
|
||||
foreach ($rows as $row) {
|
||||
$payload[] = [
|
||||
'record_code' => $row->record_code,
|
||||
'store_name' => $row->store_name,
|
||||
'custom_remark' => $row->custom_remark,
|
||||
'bill_item' => $row->bill_item,
|
||||
'merchant_order_no' => $row->merchant_order_no,
|
||||
'online_order_no' => $row->online_order_no,
|
||||
'business_type' => $row->business_type,
|
||||
'business_description' => $row->business_description,
|
||||
'account_name' => $row->account_name,
|
||||
'occurred_at' => $row->occurred_at,
|
||||
'income_amount' => $row->income_amount,
|
||||
'expense_amount' => $row->expense_amount,
|
||||
'signed_amount' => $row->signed_amount,
|
||||
'remark' => $row->remark,
|
||||
'source_updated_at' => $row->source_updated_at,
|
||||
'source_modified_by' => $row->source_modified_by,
|
||||
'source_row_number' => $row->source_row_number,
|
||||
'source_generation' => $generation,
|
||||
'source_sha256' => $hash,
|
||||
'imported_at' => $now,
|
||||
];
|
||||
}
|
||||
$this->connection->table(self::ROW_TABLE)->upsert($payload, ['record_code'], $columns);
|
||||
});
|
||||
|
||||
$this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update([
|
||||
'status' => 'succeeded',
|
||||
'row_count' => $stats['row_count'],
|
||||
'excluded_account_rows' => $stats['excluded_account_rows'],
|
||||
'store_count' => $stats['store_count'],
|
||||
'bill_item_count' => $stats['bill_item_count'],
|
||||
'account_type_counts' => json_encode($stats['account_type_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'income_total' => $stats['income_total'],
|
||||
'expense_total' => $stats['expense_total'],
|
||||
'net_total' => $stats['net_total'],
|
||||
'completed_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete();
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
private function rowCells(string $xml, array $sharedStrings): array
|
||||
{
|
||||
$row = simplexml_load_string($xml);
|
||||
if (! $row instanceof SimpleXMLElement) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$cells = [];
|
||||
foreach ($row->xpath('./*[local-name()="c"]') ?: [] as $cell) {
|
||||
$reference = (string) ($cell['r'] ?? '');
|
||||
if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$type = (string) ($cell['t'] ?? '');
|
||||
if ($type === 'inlineStr') {
|
||||
$nodes = $cell->xpath('.//*[local-name()="t"]') ?: [];
|
||||
$value = implode('', array_map(static fn (SimpleXMLElement $node): string => (string) $node, $nodes));
|
||||
} else {
|
||||
$nodes = $cell->xpath('./*[local-name()="v"]') ?: [];
|
||||
$raw = isset($nodes[0]) ? (string) $nodes[0] : '';
|
||||
$value = $type === 's' ? (string) ($sharedStrings[(int) $raw] ?? '') : $raw;
|
||||
}
|
||||
$cells[$this->columnIndex($matches[1])] = $value;
|
||||
}
|
||||
|
||||
return $cells;
|
||||
}
|
||||
|
||||
/** @param array<int,string> $cells @return array<string,int> */
|
||||
private function headerMap(array $cells): ?array
|
||||
{
|
||||
$lookup = [];
|
||||
foreach ($cells as $column => $value) {
|
||||
$lookup[trim($value)] = $column;
|
||||
}
|
||||
|
||||
$mapping = [];
|
||||
foreach (self::HEADER_ALIASES as $field => $aliases) {
|
||||
foreach ($aliases as $alias) {
|
||||
if (array_key_exists($alias, $lookup)) {
|
||||
$mapping[$field] = $lookup[$alias];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (! array_key_exists($field, $mapping)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
private function required(array $values, string $key, int $row, ?string $label = null): string
|
||||
{
|
||||
$value = trim((string) ($values[$key] ?? ''));
|
||||
if ($value === '') {
|
||||
throw new RuntimeException("第 {$row} 行缺少" . ($label ?? $key) . '。');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function money(
|
||||
string $value,
|
||||
int $row,
|
||||
string $field,
|
||||
bool $absolute = false,
|
||||
bool $allowNegative = false,
|
||||
): string {
|
||||
$value = str_replace([',', ',', '¥', '¥'], '', trim($value));
|
||||
$value = $value === '' ? '0' : $value;
|
||||
if (! is_numeric($value) || (! $allowNegative && ! $absolute && bccomp($value, '0', 6) < 0)) {
|
||||
throw new RuntimeException("第 {$row} 行{$field}不是有效金额。");
|
||||
}
|
||||
if ($absolute && bccomp($value, '0', 6) < 0) {
|
||||
$value = bcmul($value, '-1', 6);
|
||||
}
|
||||
|
||||
return bcadd($value, '0', 2);
|
||||
}
|
||||
|
||||
private function controlAmount(string $value, string $label): string
|
||||
{
|
||||
$value = str_replace([',', ',', '¥', '¥'], '', trim($value));
|
||||
if (! is_numeric($value) || bccomp($value, '0', 6) < 0) {
|
||||
throw new RuntimeException("权威{$label}控制数无效。");
|
||||
}
|
||||
|
||||
return bcadd($value, '0', 2);
|
||||
}
|
||||
|
||||
private function dateTime(string $value, int $row, string $field): string
|
||||
{
|
||||
$parsed = $this->parseDateTime($value);
|
||||
if ($parsed === null) {
|
||||
throw new RuntimeException("第 {$row} 行{$field}不是有效日期时间。");
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function optionalDateTime(string $value, int $row, string $field): ?string
|
||||
{
|
||||
if (trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->dateTime($value, $row, $field);
|
||||
}
|
||||
|
||||
private function parseDateTime(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
try {
|
||||
if (is_numeric($value)) {
|
||||
$seconds = (int) round((((float) $value) - 25569) * 86400);
|
||||
|
||||
return Carbon::createFromTimestampUTC($seconds)->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
return Carbon::parse($value)->format('Y-m-d H:i:s');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function nullable(string $value, int $max = 255): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? null : mb_substr($value, 0, $max);
|
||||
}
|
||||
|
||||
private function columnIndex(string $letters): int
|
||||
{
|
||||
$index = 0;
|
||||
foreach (str_split(strtoupper($letters)) as $letter) {
|
||||
$index = ($index * 26) + ord($letter) - 64;
|
||||
}
|
||||
|
||||
return $index - 1;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function sharedStrings(string $path, ZipArchive $archive): array
|
||||
{
|
||||
if ($archive->locateName('xl/sharedStrings.xml') === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$reader = new XMLReader();
|
||||
if (! $reader->open($this->zipUri($path, 'xl/sharedStrings.xml'), null, LIBXML_NONET | LIBXML_COMPACT)) {
|
||||
throw new RuntimeException('无法读取 XLSX 共享文本。');
|
||||
}
|
||||
|
||||
$strings = [];
|
||||
try {
|
||||
while ($reader->read()) {
|
||||
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'si') {
|
||||
continue;
|
||||
}
|
||||
$item = simplexml_load_string($reader->readOuterXml());
|
||||
$nodes = $item instanceof SimpleXMLElement ? ($item->xpath('.//*[local-name()="t"]') ?: []) : [];
|
||||
$strings[] = implode('', array_map(
|
||||
static fn (SimpleXMLElement $node): string => (string) $node,
|
||||
$nodes,
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
|
||||
return $strings;
|
||||
}
|
||||
|
||||
private function firstWorksheet(ZipArchive $archive): string
|
||||
{
|
||||
$worksheets = [];
|
||||
for ($index = 0; $index < $archive->numFiles; $index++) {
|
||||
$name = $archive->getNameIndex($index);
|
||||
if (is_string($name) && preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name) === 1) {
|
||||
$worksheets[] = $name;
|
||||
}
|
||||
}
|
||||
sort($worksheets, SORT_NATURAL);
|
||||
if ($worksheets === []) {
|
||||
throw new RuntimeException('XLSX 文件中没有工作表。');
|
||||
}
|
||||
|
||||
return $worksheets[0];
|
||||
}
|
||||
|
||||
private function zipUri(string $path, string $entry): string
|
||||
{
|
||||
return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry;
|
||||
}
|
||||
|
||||
private function result(array $row, string $status): array
|
||||
{
|
||||
return [
|
||||
'status' => $status,
|
||||
'row_count' => (int) ($row['row_count'] ?? 0),
|
||||
'excluded_account_rows' => (int) ($row['excluded_account_rows'] ?? 0),
|
||||
'store_count' => (int) ($row['store_count'] ?? 0),
|
||||
'bill_item_count' => (int) ($row['bill_item_count'] ?? 0),
|
||||
'account_type_counts' => $this->accountTypeCounts($row['account_type_counts'] ?? []),
|
||||
'income_total' => bcadd((string) ($row['income_total'] ?? '0'), '0', 2),
|
||||
'expense_total' => bcadd((string) ($row['expense_total'] ?? '0'), '0', 2),
|
||||
'net_total' => bcadd((string) ($row['net_total'] ?? '0'), '0', 2),
|
||||
'period_start' => (string) ($row['period_start'] ?? ''),
|
||||
'period_end' => (string) ($row['period_end'] ?? ''),
|
||||
'source_sha256' => (string) ($row['source_sha256'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,int> */
|
||||
private function accountTypeCounts(mixed $value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
try {
|
||||
$value = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
foreach ($value as $account => $count) {
|
||||
if (is_string($account) && $account !== '' && is_numeric($count)) {
|
||||
$counts[$account] = (int) $count;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
}
|
||||
@ -39,6 +39,25 @@ class ProductPriceWorkbookImporter
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string,array{label:string,type:string}> */
|
||||
public function templateFields(): array
|
||||
{
|
||||
$fields = [];
|
||||
|
||||
foreach (self::HEADERS as $label => $field) {
|
||||
if ($field === 'upstream_key') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fields[$field] = [
|
||||
'label' => $label,
|
||||
'type' => isset(self::TEXT_LIMITS[$field]) ? 'text' : 'number',
|
||||
];
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/** @return array{created:int,updated:int,rows:int} */
|
||||
public function import(UploadedFile $file, int $companyId, ?int $userId): array
|
||||
{
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SupplierPayableImportTemplate
|
||||
{
|
||||
/** @param array<string,array{label:string,type:string}> $fields */
|
||||
public function download(string $title, array $fields): StreamedResponse
|
||||
{
|
||||
return response()->streamDownload(function () use ($title, $fields): void {
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle($this->sheetTitle($title));
|
||||
|
||||
$headers = array_merge(['记录标识'], array_column($fields, 'label'));
|
||||
$sheet->fromArray($headers, null, 'A1');
|
||||
$lastColumn = Coordinate::stringFromColumnIndex(count($headers));
|
||||
$headerRange = 'A1:' . $lastColumn . '1';
|
||||
|
||||
$sheet->getStyle($headerRange)->applyFromArray([
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'D8D2C7']]],
|
||||
]);
|
||||
$sheet->getRowDimension(1)->setRowHeight(24);
|
||||
$sheet->freezePane('A2');
|
||||
$sheet->setAutoFilter($headerRange);
|
||||
$sheet->getColumnDimension('A')->setWidth(38);
|
||||
|
||||
$columnIndex = 2;
|
||||
foreach ($fields as $definition) {
|
||||
$column = Coordinate::stringFromColumnIndex($columnIndex);
|
||||
$width = max(14, min(30, mb_strlen($definition['label']) * 2 + 4));
|
||||
$sheet->getColumnDimension($column)->setWidth($width);
|
||||
if ($definition['type'] === 'number') {
|
||||
$sheet->getStyle($column . '2:' . $column . '10001')->getNumberFormat()->setFormatCode('0.0000');
|
||||
} else {
|
||||
$sheet->getStyle($column . '2:' . $column . '10001')->getNumberFormat()->setFormatCode('@');
|
||||
}
|
||||
$columnIndex++;
|
||||
}
|
||||
$sheet->getStyle('A2:A10001')->getNumberFormat()->setFormatCode('@');
|
||||
|
||||
$instructions = $spreadsheet->createSheet();
|
||||
$instructions->setTitle('填写说明');
|
||||
$instructions->fromArray([
|
||||
['项目', '说明'],
|
||||
['记录标识', '必填。请从本页面导出的文件中复制,不能修改。'],
|
||||
['日期', '请填写 YYYY-MM-DD,例如 2026-09-17。'],
|
||||
['月份', '请填写 YYYY-MM,例如 2026-09。'],
|
||||
['数字', '最多支持 4 位小数;金额可直接填写数字。'],
|
||||
['空白单元格', '导入后将清空该字段,请确认后再导入。'],
|
||||
], null, 'A1');
|
||||
$instructions->getStyle('A1:B1')->applyFromArray([
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
|
||||
]);
|
||||
$instructions->getColumnDimension('A')->setWidth(18);
|
||||
$instructions->getColumnDimension('B')->setWidth(58);
|
||||
$instructions->getStyle('A1:B6')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true);
|
||||
foreach (range(1, 6) as $row) {
|
||||
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 24 : 32);
|
||||
}
|
||||
|
||||
$spreadsheet->setActiveSheetIndex(0);
|
||||
(new Xlsx($spreadsheet))->save('php://output');
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}, $title . '_导入模板_' . now()->format('Ymd') . '.xlsx', [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
private function sheetTitle(string $title): string
|
||||
{
|
||||
$title = str_replace(['\\', '/', '?', '*', '[', ']', ':'], '', $title) ?: '导入模板';
|
||||
|
||||
return mb_substr($title, 0, 31);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SupplierPayableMaintenanceRegistry
|
||||
{
|
||||
/** @return array<string,array{label:string,type:string}> */
|
||||
public function fields(string $dataset): array
|
||||
{
|
||||
$money = static fn (string $label): array => ['label' => $label, 'type' => 'number'];
|
||||
$text = static fn (string $label): array => ['label' => $label, 'type' => 'text'];
|
||||
$date = static fn (string $label): array => ['label' => $label, 'type' => 'date'];
|
||||
$month = static fn (string $label): array => ['label' => $label, 'type' => 'month'];
|
||||
|
||||
return match ($dataset) {
|
||||
'payment-reconciliation' => [
|
||||
'supplier' => $text('供应商'), 'skuCode' => $text('货号'), 'productCode' => $text('商品编码'),
|
||||
'skuName' => $text('规格名称'), 'qty' => $money('数量'), 'unitPrice' => $money('单价'),
|
||||
'amount' => $money('金额'), 'guessUnitPrice' => $money('预估单价'), 'guessAmount' => $money('预估金额'),
|
||||
'supplierGoodsCode' => $text('供应商商品编码'), 'remark' => $text('备注'),
|
||||
'warehouse' => $text('仓库名称'), 'expressName' => $text('快递名称'), 'expressNo' => $text('快递单号'),
|
||||
],
|
||||
'return-reconciliation', 'return-unpriced' => [
|
||||
'send_time' => $date('发货时间'), 'supplier' => $text('明细供应商'), 'barcode' => $text('条形码'),
|
||||
'sku_name' => $text('商品名称'), 'qty' => $money('发货量'), 'unit_price' => $money('单价'),
|
||||
'amount' => $money('金额'), 'guess_unit_price' => $money('预估单价'), 'guess_amount' => $money('预估金额'),
|
||||
'supplier_barcode' => $text('供应商条码'), 'remark' => $text('备注'), 'title' => $text('标题'),
|
||||
'warehouse' => $text('仓库名称'), 'express_no' => $text('快递单号'),
|
||||
'express_name' => $text('快递名称'), 'labels' => $text('标记多标签'), 'reconciler' => $text('对账人'),
|
||||
],
|
||||
'invoice-deductions' => [
|
||||
'month_str' => $month('日期'), 'category' => $text('类别'), 'payee' => $text('打款抬头'),
|
||||
'receiver' => $text('收款方'), 'board_factory' => $text('供板材厂家/代扣厂家'),
|
||||
'subsidy_fee' => $money('补贴费用'), 'remark' => $text('备注'), 'price' => $money('对私打款金额'),
|
||||
'detail' => $text('明细'), 'note' => $text('注明'), 'person_charge' => $text('负责人'),
|
||||
],
|
||||
'compensation-details' => [
|
||||
'month' => $month('日期'), 'supplier' => $text('供应商'), 'amount' => $money('赔付金额'),
|
||||
'reason' => $text('赔付原因'), 'remark' => $text('备注'),
|
||||
],
|
||||
'advance-details' => [
|
||||
'supplier' => $text('供应商'), 'contract_name' => $text('协议签订名称'),
|
||||
'advance_amount' => $money('借支金额'), 'actual_pay' => $money('实际打款金额'),
|
||||
'month_deduct' => $money('本月扣款金额'), 'should_deduct' => $money('应扣金额'),
|
||||
'deduct_start_month' => $month('分期起始扣款月份'), 'deduct_end_month' => $month('分期最后扣款月份'),
|
||||
'remain_periods' => $money('分期剩余期数'), 'month_str' => $month('借支日期'),
|
||||
'loan_term' => $text('借款期限'), 'remark' => $text('备注'), 'is_paid' => $text('是否打款'),
|
||||
'pay_month' => $month('打款月份'), 'serial_no' => $text('流水号'),
|
||||
'loan_agreement' => $text('借支协议'), 'person_charge' => $text('负责人'),
|
||||
],
|
||||
'sample-deductions' => [
|
||||
'finish_time' => $date('日期'), 'department' => $text('部门'), 'creator' => $text('申请人'),
|
||||
'sku_id' => $text('商品代码'), 'skuName' => $text('商品名称'), 'num' => $money('数量'),
|
||||
'supplier' => $text('厂家'), 'purchase' => $text('对应采购'), 'productCode' => $text('扣款厂家商品代码'),
|
||||
'shipping_fee' => $money('运费'), 'cost_price' => $money('成本价'),
|
||||
'total_amount' => $money('合计扣款金额'), 'estimated_price' => $money('预估价'),
|
||||
'estimated_price_all' => $money('预估金额'), 'remark' => $text('备注'),
|
||||
],
|
||||
'delivery-unpriced' => [
|
||||
'transactionNo' => $text('来源单号'), 'distributionNo' => $text('单据编号(采购单号)'),
|
||||
'date' => $date('创建时间'), 'supplier' => $text('供应商'), 'sku' => $text('货号'),
|
||||
'barCode' => $text('条码'), 'skuName' => $text('规格名'), 'supplier_barcode' => $text('供应商条码'),
|
||||
'remark' => $text('备注'), 'warehouse' => $text('仓库'), 'expressName' => $text('快递名称'),
|
||||
'expressNo' => $text('快递单号'), 'purchase' => $text('负责人'),
|
||||
],
|
||||
'other-adjustments' => [
|
||||
'project' => $text('项目'), 'month_str' => $month('日期'), 'supplier' => $text('厂家'),
|
||||
'supplement_amount' => $money('补款金额'), 'supplement_reason' => $text('补款原因'),
|
||||
'remark' => $text('备注'), 'remark2' => $text('备注2'), 'person_charge' => $text('负责人'),
|
||||
],
|
||||
'dropshipping-express' => [
|
||||
'reconciliation_person' => $text('对账人'), 'month_str' => $month('日期'), 'supplier' => $text('供应商'),
|
||||
'express_freight' => $money('快递运费'), 'express_account' => $money('快递记账'),
|
||||
'replenish_freight' => $money('补运费'), 'replenish_account' => $money('补记账'),
|
||||
'return_fee' => $money('退回费'), 'intercept_fee' => $money('拦截'),
|
||||
'change_address_fee' => $money('改地址'), 'turnover_fee' => $money('翻箱费'),
|
||||
'overlong_fee' => $money('超长费'), 'total_freight' => $money('合计运费'),
|
||||
'remark' => $text('备注'), 'express_name' => $text('快递'), 'remark2' => $text('备注2'),
|
||||
],
|
||||
'board-deductions' => [
|
||||
'stock_in_date' => $date('入库日期'), 'stock_out_date' => $date('出库日期'),
|
||||
'furniture_factory' => $text('家具厂'), 'lamination_factory' => $text('压贴厂'),
|
||||
'deduction_type' => $text('类型'), 'specification' => $text('规格'), 'material_name' => $text('原料名称'),
|
||||
'received_quantity' => $money('实收数量'), 'unit_price' => $money('单价'), 'amount' => $money('金额'),
|
||||
'unit' => $text('单位'), 'batch_date' => $date('批次日期'), 'stock_out_number' => $text('出库单号'),
|
||||
'stock_out_remark' => $text('出库备注'), 'claim_number' => $text('申领单号'),
|
||||
'number_type' => $text('单号类型'), 'delivery_number' => $text('送货单号'),
|
||||
],
|
||||
'supplier-payables-report' => [
|
||||
'checker' => $text('对账人'), 'settlement_method' => $text('结算方式'), 'company' => $text('供应商公司主体名称'),
|
||||
'supplier' => $text('供应商简称'), 'express_freight' => $money('快递运费'),
|
||||
'beginning_payable' => $money('月初未付'), 'unpriced' => $money('之前未定价-本月定价'),
|
||||
'advance_loan' => $money('预支借款'), 'loan' => $money('货款金额'), 'refund' => $money('退货金额'),
|
||||
'procurement' => $money('采购小计'), 'invoice_deduction' => $money('发票扣款'),
|
||||
'board_deduction' => $money('板材扣款'), 'sample_deduction' => $money('样品扣款'),
|
||||
'total_compensation' => $money('总赔偿金额'), 'other_adjustment' => $money('其他费用'),
|
||||
'supplier_supplement' => $money('补给厂家'), 'supplier_balance' => $money('供应商余额'),
|
||||
'should_pay' => $money('应付余额'), 'estimated_price' => $money('预估金额'),
|
||||
'to_corporate' => $money('对公打款金额'), 'to_private' => $money('对私打款金额'),
|
||||
'cashier_paid' => $money('已销账金额'), 'remarks' => $text('备注'),
|
||||
'private_reason' => $text('对私原因'), 'deposit' => $money('押款金额'),
|
||||
'negative_reason' => $text('负数原因'),
|
||||
],
|
||||
default => throw ValidationException::withMessages(['dataset' => '不支持该供应商应付页面。']),
|
||||
};
|
||||
}
|
||||
|
||||
public function normalize(string $dataset, array $input): array
|
||||
{
|
||||
$fields = $this->fields($dataset);
|
||||
$values = [];
|
||||
|
||||
foreach ($fields as $field => $definition) {
|
||||
if (! array_key_exists($field, $input)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $input[$field];
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
$values[$field] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($definition['type'] === 'number') {
|
||||
$number = str_replace([',', ',', '¥', '¥'], '', trim((string) $value));
|
||||
if (preg_match('/^-?(?:\d+(?:\.\d{1,4})?|\.\d{1,4})$/', $number) !== 1
|
||||
|| bccomp(ltrim($number, '-'), '9999999999999999.9999', 4) === 1) {
|
||||
throw ValidationException::withMessages([
|
||||
$field => $definition['label'] . '必须是最多 4 位小数的有效数字。',
|
||||
]);
|
||||
}
|
||||
$values[$field] = $number;
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = trim((string) $value);
|
||||
if (mb_strlen($text) > 5000) {
|
||||
throw ValidationException::withMessages([$field => $definition['label'] . '内容过长。']);
|
||||
}
|
||||
if ($definition['type'] === 'date' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $text) !== 1) {
|
||||
throw ValidationException::withMessages([$field => $definition['label'] . '日期格式无效。']);
|
||||
}
|
||||
if ($definition['type'] === 'month' && preg_match('/^\d{4}-\d{2}$/', $text) !== 1) {
|
||||
throw ValidationException::withMessages([$field => $definition['label'] . '月份格式无效。']);
|
||||
}
|
||||
$values[$field] = $text;
|
||||
}
|
||||
|
||||
if ($values === []) {
|
||||
throw ValidationException::withMessages(['values' => '没有可保存的字段。']);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
public function publicDefinition(string $dataset): array
|
||||
{
|
||||
return [
|
||||
'dataset' => $dataset,
|
||||
'fields' => collect($this->fields($dataset))->map(
|
||||
static fn (array $definition, string $name): array => ['name' => $name] + $definition
|
||||
)->values()->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SupplierPayableOverrideStore
|
||||
{
|
||||
public function key(string $dataset, array $row): string
|
||||
{
|
||||
if (isset($row['sourceId']) && $row['sourceId'] !== '') {
|
||||
return 'id:' . (string) ($row['recordType'] ?? '') . ':' . (string) $row['sourceId'];
|
||||
}
|
||||
if (isset($row['id']) && $row['id'] !== '') {
|
||||
return 'id:' . (string) $row['id'];
|
||||
}
|
||||
|
||||
$immutable = $row;
|
||||
unset($immutable['_action_key'], $immutable['_overridden']);
|
||||
|
||||
return 'hash:' . hash('sha256', json_encode($immutable, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $rows */
|
||||
public function decorate(string $dataset, array $rows, int $companyId): array
|
||||
{
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = [];
|
||||
foreach ($rows as $row) {
|
||||
$keys[] = $this->key($dataset, $row);
|
||||
}
|
||||
|
||||
$overrides = $this->connection()->table('supplier_payable_overrides')
|
||||
->where('company_id', $companyId)
|
||||
->where('dataset', $dataset)
|
||||
->whereIn('record_key', $keys)
|
||||
->get()
|
||||
->keyBy('record_key');
|
||||
|
||||
foreach ($rows as $index => &$row) {
|
||||
$key = $keys[$index];
|
||||
$row['_action_key'] = $key;
|
||||
$override = $overrides->get($key);
|
||||
if ($override) {
|
||||
$values = json_decode((string) $override->values, true) ?: [];
|
||||
$row = array_merge($row, $values);
|
||||
$row['_overridden'] = true;
|
||||
} else {
|
||||
$row['_overridden'] = false;
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return array{created:int,updated:int} */
|
||||
public function save(string $dataset, string $recordKey, array $values, int $companyId, ?int $userId, string $source = 'manual'): array
|
||||
{
|
||||
$connection = $this->connection();
|
||||
$existing = $connection->table('supplier_payable_overrides')
|
||||
->where(['company_id' => $companyId, 'dataset' => $dataset, 'record_key' => $recordKey])
|
||||
->first();
|
||||
$before = $existing ? (json_decode((string) $existing->values, true) ?: []) : null;
|
||||
$now = now();
|
||||
|
||||
$connection->transaction(function () use ($connection, $existing, $dataset, $recordKey, $values, $companyId, $userId, $now, $source, $before): void {
|
||||
$connection->table('supplier_payable_overrides')->updateOrInsert(
|
||||
['company_id' => $companyId, 'dataset' => $dataset, 'record_key' => $recordKey],
|
||||
[
|
||||
'values' => json_encode($values, JSON_UNESCAPED_UNICODE),
|
||||
'updated_by' => $userId,
|
||||
'created_by' => $existing->created_by ?? $userId,
|
||||
'created_at' => $existing->created_at ?? $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
);
|
||||
$connection->table('supplier_payable_override_events')->insert([
|
||||
'company_id' => $companyId,
|
||||
'dataset' => $dataset,
|
||||
'record_key' => $recordKey,
|
||||
'source' => $source,
|
||||
'before_values' => $before === null ? null : json_encode($before, JSON_UNESCAPED_UNICODE),
|
||||
'after_values' => json_encode($values, JSON_UNESCAPED_UNICODE),
|
||||
'user_id' => $userId,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
});
|
||||
|
||||
return ['created' => $existing ? 0 : 1, 'updated' => $existing ? 1 : 0];
|
||||
}
|
||||
|
||||
public function transaction(Closure $callback): mixed
|
||||
{
|
||||
return $this->connection()->transaction($callback);
|
||||
}
|
||||
|
||||
private function connection(): ConnectionInterface
|
||||
{
|
||||
return DB::connection(config('database.supplier_payable_maintenance_connection'));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Throwable;
|
||||
|
||||
class SupplierPayableWorkbookImporter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SupplierPayableMaintenanceRegistry $registry,
|
||||
private readonly SupplierPayableOverrideStore $overrides,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{created:int,updated:int,rows:int} */
|
||||
public function import(string $dataset, UploadedFile $file, int $companyId, ?int $userId): array
|
||||
{
|
||||
$definitions = $this->registry->fields($dataset);
|
||||
$lookup = ['记录标识' => '_action_key', 'recordkey' => '_action_key', '_action_key' => '_action_key'];
|
||||
foreach ($definitions as $field => $definition) {
|
||||
$lookup[$this->header($field)] = $field;
|
||||
$lookup[$this->header($definition['label'])] = $field;
|
||||
}
|
||||
|
||||
try {
|
||||
$readerType = match (mb_strtolower($file->getClientOriginalExtension())) {
|
||||
'xlsx' => 'Xlsx',
|
||||
'xls' => 'Xls',
|
||||
'csv' => 'Csv',
|
||||
default => throw new \RuntimeException('Unsupported workbook format.'),
|
||||
};
|
||||
$spreadsheet = IOFactory::createReader($readerType)->load($file->getRealPath());
|
||||
[$rows, $headerRow, $columns] = $this->bestSheet($spreadsheet, $lookup);
|
||||
} catch (Throwable) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
|
||||
]);
|
||||
} finally {
|
||||
if (isset($spreadsheet)) {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
if ($rows === null || ! in_array('_action_key', $columns, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '未找到“记录标识”列,请先导出当前数据,再使用导出的表格修改。',
|
||||
]);
|
||||
}
|
||||
|
||||
$records = [];
|
||||
foreach (array_slice($rows, $headerRow + 1, null, true) as $rowIndex => $row) {
|
||||
$line = $rowIndex + 1;
|
||||
$key = '';
|
||||
$rawValues = [];
|
||||
foreach ($columns as $columnIndex => $field) {
|
||||
if ($field === '_action_key') {
|
||||
$key = trim((string) ($row[$columnIndex] ?? ''));
|
||||
} else {
|
||||
$rawValues[$field] = $row[$columnIndex] ?? null;
|
||||
}
|
||||
}
|
||||
if ($key === '' && collect($rawValues)->every(static fn (mixed $value): bool => trim((string) $value) === '')) {
|
||||
continue;
|
||||
}
|
||||
if (! str_starts_with($key, 'id:') && ! preg_match('/^hash:[a-f0-9]{64}$/', $key)) {
|
||||
throw ValidationException::withMessages(['import' => "第 {$line} 行“记录标识”无效,请勿修改该列。"]);
|
||||
}
|
||||
if (isset($records[$key])) {
|
||||
throw ValidationException::withMessages(['import' => "第 {$line} 行“记录标识”重复。"]);
|
||||
}
|
||||
try {
|
||||
$records[$key] = $this->registry->normalize($dataset, $rawValues);
|
||||
} catch (ValidationException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => "第 {$line} 行:" . collect($exception->errors())->flatten()->first(),
|
||||
]);
|
||||
}
|
||||
if (count($records) > 10000) {
|
||||
throw ValidationException::withMessages(['import' => '单次最多导入 10,000 条记录,请拆分文件后重试。']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($records === []) {
|
||||
throw ValidationException::withMessages(['import' => '表格中没有可导入的数据行。']);
|
||||
}
|
||||
|
||||
$counts = ['created' => 0, 'updated' => 0];
|
||||
$this->overrides->transaction(function () use ($records, $dataset, $companyId, $userId, &$counts): void {
|
||||
foreach ($records as $key => $values) {
|
||||
$result = $this->overrides->save($dataset, $key, $values, $companyId, $userId, 'import');
|
||||
$counts['created'] += $result['created'];
|
||||
$counts['updated'] += $result['updated'];
|
||||
}
|
||||
});
|
||||
|
||||
return $counts + ['rows' => count($records)];
|
||||
}
|
||||
|
||||
private function bestSheet(mixed $spreadsheet, array $lookup): array
|
||||
{
|
||||
$best = [null, null, []];
|
||||
$score = 0;
|
||||
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
|
||||
$rows = $worksheet->toArray(null, false, false, false);
|
||||
foreach (array_slice($rows, 0, 20, true) as $rowIndex => $row) {
|
||||
$columns = [];
|
||||
foreach ($row as $columnIndex => $heading) {
|
||||
$key = $this->header($heading);
|
||||
if ($key !== '' && isset($lookup[$key])) {
|
||||
$columns[$columnIndex] = $lookup[$key];
|
||||
}
|
||||
}
|
||||
if (count($columns) > $score) {
|
||||
$best = [$rows, $rowIndex, $columns];
|
||||
$score = count($columns);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $best;
|
||||
}
|
||||
|
||||
private function header(mixed $value): string
|
||||
{
|
||||
return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim((string) $value)));
|
||||
}
|
||||
}
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
166
resources/assets/js/views/ecommerce-finance/supplier-payable-maintenance.js
vendored
Normal file
166
resources/assets/js/views/ecommerce-finance/supplier-payable-maintenance.js
vendored
Normal file
@ -0,0 +1,166 @@
|
||||
import Vue from 'vue';
|
||||
import { Dialog, Form, FormItem, Input, DatePicker, Button } from 'element-ui';
|
||||
|
||||
[Dialog, Form, FormItem, Input, DatePicker, Button].forEach(component => Vue.component(component.name, component));
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
maintenance: {
|
||||
fields: [],
|
||||
form: {},
|
||||
row: null,
|
||||
selectedRow: null,
|
||||
visible: false,
|
||||
saving: false,
|
||||
importing: false,
|
||||
importVisible: false,
|
||||
importFile: null,
|
||||
importFileName: ''
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
maintenanceBaseUrl() {
|
||||
return url + '/workspace/supplier-payables/actions/' + this.maintenanceDataset;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
tableData() {
|
||||
this.maintenanceSelect(null);
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
axios.get(this.maintenanceBaseUrl + '/definition')
|
||||
.then(response => { this.maintenance.fields = response.data.fields || []; })
|
||||
.catch(() => this.$message.error('获取维护字段失败'));
|
||||
},
|
||||
|
||||
methods: {
|
||||
maintenanceSelect(row) {
|
||||
this.maintenance.selectedRow = row || null;
|
||||
},
|
||||
|
||||
maintenanceEditSelected() {
|
||||
const row = this.maintenance.selectedRow;
|
||||
if (!row || !row._action_key) {
|
||||
this.$message.warning('请先选择一条记录');
|
||||
return;
|
||||
}
|
||||
if (!this.maintenance.fields.length) {
|
||||
this.$message.warning('编辑字段正在加载,请稍后重试');
|
||||
return;
|
||||
}
|
||||
this.maintenanceOpen(row);
|
||||
},
|
||||
|
||||
maintenanceOpen(row) {
|
||||
if (!row || !row._action_key || !this.maintenance.fields.length) return;
|
||||
const form = {};
|
||||
this.maintenance.fields.forEach(field => {
|
||||
const value = row[field.name];
|
||||
form[field.name] = value == null ? '' : String(value);
|
||||
});
|
||||
this.maintenance.row = row;
|
||||
this.maintenance.form = form;
|
||||
this.maintenance.visible = true;
|
||||
},
|
||||
|
||||
maintenanceClose() {
|
||||
this.maintenance.row = null;
|
||||
this.maintenance.form = {};
|
||||
this.maintenance.visible = false;
|
||||
this.maintenance.saving = false;
|
||||
},
|
||||
|
||||
maintenanceSave() {
|
||||
if (!this.maintenance.row || !this.maintenance.row._action_key) {
|
||||
this.$message.error('当前记录缺少维护标识,请刷新后重试');
|
||||
return;
|
||||
}
|
||||
this.maintenance.saving = true;
|
||||
axios.patch(
|
||||
this.maintenanceBaseUrl + '/' + this.maintenanceRecordKey(this.maintenance.row._action_key),
|
||||
{ values: this.maintenance.form },
|
||||
{ headers: { Accept: 'application/json', 'Content-Type': 'application/json' } }
|
||||
).then(response => {
|
||||
this.$message.success(response.data.message || '保存成功');
|
||||
this.maintenance.visible = false;
|
||||
this.fetchData();
|
||||
}).catch(error => this.$message.error(this.maintenanceError(error, '保存失败')))
|
||||
.finally(() => { this.maintenance.saving = false; });
|
||||
},
|
||||
|
||||
maintenanceRecordKey(recordKey) {
|
||||
return String(recordKey).split(':').map(encodeURIComponent).join(':');
|
||||
},
|
||||
|
||||
maintenanceOpenImport() {
|
||||
this.maintenance.importFile = null;
|
||||
this.maintenance.importFileName = '';
|
||||
this.maintenance.importVisible = true;
|
||||
},
|
||||
|
||||
maintenanceCloseImport() {
|
||||
if (this.maintenance.importing) return;
|
||||
this.maintenance.importVisible = false;
|
||||
this.maintenance.importFile = null;
|
||||
this.maintenance.importFileName = '';
|
||||
if (this.$refs.maintenanceImport) this.$refs.maintenanceImport.value = '';
|
||||
},
|
||||
|
||||
maintenanceChooseImport() {
|
||||
this.$refs.maintenanceImport.click();
|
||||
},
|
||||
|
||||
maintenanceSelectImport(event) {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
if (!file) return;
|
||||
this.maintenance.importFile = file;
|
||||
this.maintenance.importFileName = file.name;
|
||||
},
|
||||
|
||||
maintenanceImport() {
|
||||
const file = this.maintenance.importFile;
|
||||
if (!file) {
|
||||
this.$message.warning('请先选择导入文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append('import', file);
|
||||
this.maintenance.importing = true;
|
||||
axios.post(this.maintenanceBaseUrl + '/import', body, {
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'multipart/form-data' }
|
||||
}).then(response => {
|
||||
this.$message.success(response.data.message || '导入完成');
|
||||
this.maintenance.importVisible = false;
|
||||
this.maintenance.importFile = null;
|
||||
this.maintenance.importFileName = '';
|
||||
if (this.$refs.maintenanceImport) this.$refs.maintenanceImport.value = '';
|
||||
this.fetchData();
|
||||
}).catch(error => this.$message.error(this.maintenanceError(error, '导入失败')))
|
||||
.finally(() => { this.maintenance.importing = false; });
|
||||
},
|
||||
|
||||
maintenanceExport() {
|
||||
const params = Object.assign({}, this.buildParams ? this.buildParams() : {});
|
||||
delete params.page;
|
||||
delete params.limit;
|
||||
const query = new URLSearchParams(params).toString();
|
||||
window.location.href = this.maintenanceBaseUrl + '/export' + (query ? '?' + query : '');
|
||||
},
|
||||
|
||||
maintenanceError(error, fallback) {
|
||||
const errors = error && error.response && error.response.data && error.response.data.errors;
|
||||
if (errors) {
|
||||
const first = Object.values(errors)[0];
|
||||
if (Array.isArray(first) && first[0]) return first[0];
|
||||
}
|
||||
return (error && error.response && error.response.data && error.response.data.message) || fallback;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -6,7 +6,7 @@
|
||||
</div>
|
||||
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">借支总金额</div>
|
||||
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
|
||||
@ -25,8 +25,7 @@
|
||||
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
|
||||
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
@include('business.pages.supplier-payables.maintenance-actions')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -35,7 +34,7 @@
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
|
||||
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
|
||||
<el-table-column prop="supplier" label="供应商" width="140" fixed="left" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="协议签订名称" width="140">
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
$boardDeductionFilters = $content['boardDeductionFilters'] ?? [];
|
||||
$boardDeductionError = $content['boardDeductionError'] ?? null;
|
||||
$boardDeductionStatus = data_get($content, 'boardDeductionStatus.X-Caiwu-Data-Status');
|
||||
$boardActions = url('/' . (company_id() ?: 1) . '/workspace/supplier-payables/actions/board-deductions');
|
||||
@endphp
|
||||
|
||||
<div class="supplier-board-page">
|
||||
@ -53,6 +54,7 @@
|
||||
|
||||
.supplier-board-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@ -78,6 +80,8 @@
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.supplier-board-button .material-icons-outlined { font-size: 1.125rem; }
|
||||
|
||||
.supplier-board-alert {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
@ -189,17 +193,31 @@
|
||||
<label for="board-deduction-claim-number">申领单号</label>
|
||||
<input id="board-deduction-claim-number" name="claim_number" type="text" value="{{ $boardDeductionFilters['claim_number'] ?? '' }}" placeholder="请输入申领单号">
|
||||
</div>
|
||||
<div class="supplier-board-actions">
|
||||
<button type="submit" class="supplier-board-button is-primary">
|
||||
<div class="supplier-board-actions supplier-page-actions">
|
||||
<button type="button" class="supplier-board-button supplier-action-button" data-supplier-import-open>
|
||||
<span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>导入</span>
|
||||
</button>
|
||||
<a href="{{ $boardActions . '/export?' . http_build_query(request()->except('page')) }}" class="supplier-board-button supplier-action-button">
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span>
|
||||
</a>
|
||||
<button type="submit" class="supplier-board-button supplier-action-button is-primary">
|
||||
<span class="material-icons-outlined" aria-hidden="true">search</span>
|
||||
<span>查询</span>
|
||||
</button>
|
||||
<a href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}" class="supplier-board-button">
|
||||
<a href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}" class="supplier-board-button supplier-action-button">
|
||||
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
|
||||
<span>重置</span>
|
||||
</a>
|
||||
<button type="button" class="supplier-board-button supplier-action-button" data-supplier-maintenance-open>
|
||||
<span class="material-icons-outlined" aria-hidden="true">edit</span>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@include('business.pages.supplier-payables.maintenance-import-modal', [
|
||||
'supplierImportAction' => $boardActions . '/import',
|
||||
'supplierTemplateUrl' => $boardActions . '/template',
|
||||
])
|
||||
|
||||
@if ($boardDeductionError)
|
||||
<div class="supplier-board-alert" role="alert">{{ $boardDeductionError }}</div>
|
||||
@ -231,7 +249,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($boardDeductions ?? [] as $row)
|
||||
<tr>
|
||||
<tr data-supplier-maintenance-row data-key="{{ $row->_action_key }}" data-row="{!! e(json_encode($row, JSON_UNESCAPED_UNICODE)) !!}" tabindex="0" aria-selected="false">
|
||||
<td>{{ $row->stock_in_date ?: '-' }}</td>
|
||||
<td>{{ $row->stock_out_date ?: '-' }}</td>
|
||||
<td>{{ $row->furniture_factory ?: '-' }}</td>
|
||||
@ -277,3 +295,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@include('business.pages.supplier-payables.maintenance-blade-modal')
|
||||
|
||||
@ -46,8 +46,7 @@
|
||||
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
|
||||
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
@include('business.pages.supplier-payables.maintenance-actions')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -56,7 +55,7 @@
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
|
||||
<el-table-column prop="month" label="日期" width="100" fixed="left"></el-table-column>
|
||||
<el-table-column prop="supplier" label="供应商" width="260" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="amount" label="赔付金额" width="140" align="right">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<div id="delivery-unpriced-app" class="delivery-unpriced-page">
|
||||
<div v-cloak>
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">未定价记录数</div>
|
||||
<div class="text-2xl font-bold text-orange-500">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
|
||||
@ -29,8 +29,7 @@
|
||||
start-placeholder="日期开始" end-placeholder="日期结束" size="small"
|
||||
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
@include('business.pages.supplier-payables.maintenance-actions')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -39,7 +38,7 @@
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
|
||||
<el-table-column prop="transactionNo" label="来源单号" width="180" fixed="left" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="distributionNo" label="单据编号(采购单号)" width="180" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="date" label="创建时间" width="110"></el-table-column>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<div id="dropshipping-express-app" class="dropshipping-express-page">
|
||||
<div v-cloak>
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">合计运费</div>
|
||||
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
|
||||
@ -23,8 +23,7 @@
|
||||
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
|
||||
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
@include('business.pages.supplier-payables.maintenance-actions')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -33,7 +32,7 @@
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
|
||||
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
|
||||
<el-table-column prop="reconciliation_person" label="对账人" width="100" fixed="left"></el-table-column>
|
||||
<el-table-column prop="month_str" label="日期" width="100"></el-table-column>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<div id="invoice-deduction-app" class="invoice-deduction-page">
|
||||
<div v-cloak>
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">扣款总金额</div>
|
||||
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalPrice) }}</div>
|
||||
@ -20,8 +20,7 @@
|
||||
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
|
||||
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
@include('business.pages.supplier-payables.maintenance-actions')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -30,7 +29,7 @@
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
|
||||
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
|
||||
<el-table-column prop="month_str" label="日期" width="100" fixed="left"></el-table-column>
|
||||
<el-table-column label="类别" width="100">
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
<div class="supplier-page-actions">
|
||||
@include('business.pages.supplier-payables.maintenance-toolbar')
|
||||
<el-button size="small" type="primary" class="supplier-action-button is-primary" @click="onFilterChange">
|
||||
<span class="material-icons-outlined" aria-hidden="true">search</span><span>查询</span>
|
||||
</el-button>
|
||||
<el-button size="small" class="supplier-action-button" @click="onReset">
|
||||
<span class="material-icons-outlined" aria-hidden="true">refresh</span><span>重置</span>
|
||||
</el-button>
|
||||
@include('business.pages.supplier-payables.maintenance-edit-button')
|
||||
</div>
|
||||
@ -0,0 +1,109 @@
|
||||
@php
|
||||
$maintenance = $content['supplierPayableMaintenance'] ?? ['dataset' => '', 'fields' => []];
|
||||
$maintenanceAction = url('/' . (company_id() ?: 1) . '/workspace/supplier-payables/actions/' . $maintenance['dataset']);
|
||||
@endphp
|
||||
|
||||
<div class="supplier-maintenance-modal" data-supplier-maintenance-modal hidden>
|
||||
<div class="supplier-maintenance-modal__backdrop" data-supplier-maintenance-close></div>
|
||||
<section class="supplier-maintenance-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="supplier-maintenance-modal-title">
|
||||
<header class="supplier-maintenance-modal__header">
|
||||
<h2 id="supplier-maintenance-modal-title">编辑记录</h2>
|
||||
<button type="button" data-supplier-maintenance-close aria-label="关闭" title="关闭"><span class="material-icons-outlined">close</span></button>
|
||||
</header>
|
||||
<form method="POST" data-supplier-maintenance-form>
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<div class="supplier-maintenance-modal__grid">
|
||||
@foreach ($maintenance['fields'] as $field)
|
||||
<label>
|
||||
<span>{{ $field['label'] }}</span>
|
||||
<input name="values[{{ $field['name'] }}]" data-maintenance-field="{{ $field['name'] }}"
|
||||
type="{{ $field['type'] === 'number' ? 'number' : ($field['type'] === 'date' ? 'date' : ($field['type'] === 'month' ? 'month' : 'text')) }}"
|
||||
@if ($field['type'] === 'number') step="0.0001" @endif>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" class="supplier-maintenance-button" data-supplier-maintenance-close>取消</button>
|
||||
<button type="submit" class="supplier-maintenance-button is-primary">保存</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@once
|
||||
<style>
|
||||
.supplier-maintenance-modal[hidden] { display: none; }
|
||||
.supplier-maintenance-modal { position: fixed; inset: 0; z-index: 1050; display: grid; place-items: center; padding: 1rem; }
|
||||
.supplier-maintenance-modal__backdrop { position: absolute; inset: 0; background: rgba(5, 5, 6, .58); }
|
||||
.supplier-maintenance-modal__dialog { position: relative; width: min(760px, 94vw); max-height: 88vh; overflow: hidden; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; box-shadow: 0 4px 8px rgba(23, 23, 23, .10); }
|
||||
.supplier-maintenance-modal__header { display: flex; align-items: center; justify-content: space-between; padding: 1rem; border-bottom: 1px solid #D8D2C7; }
|
||||
.supplier-maintenance-modal__header h2 { margin: 0; color: #171717; font-size: .875rem; font-weight: 600; }
|
||||
.supplier-maintenance-modal__header button { border: 0; background: transparent; color: #595959; }
|
||||
.supplier-maintenance-modal__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem 1rem; max-height: 62vh; overflow-y: auto; padding: 1rem; }
|
||||
.supplier-maintenance-modal__grid label { display: flex; flex-direction: column; gap: .35rem; color: #595959; font-size: .75rem; font-weight: 600; }
|
||||
.supplier-maintenance-modal__grid input { height: 34px; padding: 0 10px; border: 1px solid #D8D2C7; border-radius: 8px; color: #171717; font-size: .8125rem; letter-spacing: 0; }
|
||||
.supplier-maintenance-modal__grid input:focus { border-color: #856632; outline: 0; box-shadow: 0 0 0 3px rgba(133, 102, 50, .20); }
|
||||
.supplier-maintenance-modal footer { display: flex; justify-content: flex-end; gap: .5rem; padding: 1rem; border-top: 1px solid #D8D2C7; }
|
||||
.supplier-maintenance-button { height: 34px; padding: 0 14px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font-size: .8125rem; font-weight: 500; }
|
||||
.supplier-maintenance-button.is-primary { border-color: #856632; background: #856632; color: #fff; }
|
||||
[data-supplier-maintenance-row] { cursor: pointer; }
|
||||
[data-supplier-maintenance-row].supplier-maintenance-row-selected { background: #F7F0DE; outline: 2px solid #856632; outline-offset: -2px; }
|
||||
@media (max-width: 640px) { .supplier-maintenance-modal__grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@endonce
|
||||
|
||||
@push('body_end')
|
||||
<script>
|
||||
(() => {
|
||||
const modal = document.querySelector('[data-supplier-maintenance-modal]');
|
||||
if (!modal || modal.dataset.ready) return;
|
||||
modal.dataset.ready = '1';
|
||||
const form = modal.querySelector('[data-supplier-maintenance-form]');
|
||||
const openButton = document.querySelector('[data-supplier-maintenance-open]');
|
||||
const rows = document.querySelectorAll('[data-supplier-maintenance-row]');
|
||||
const base = @json($maintenanceAction);
|
||||
let selectedRow = null;
|
||||
let returnFocus = null;
|
||||
const close = () => {
|
||||
modal.hidden = true;
|
||||
form.action = '';
|
||||
form.reset();
|
||||
returnFocus?.focus();
|
||||
};
|
||||
const selectRow = row => {
|
||||
selectedRow?.classList.remove('supplier-maintenance-row-selected');
|
||||
selectedRow?.setAttribute('aria-selected', 'false');
|
||||
selectedRow = row;
|
||||
selectedRow?.classList.add('supplier-maintenance-row-selected');
|
||||
selectedRow?.setAttribute('aria-selected', 'true');
|
||||
};
|
||||
const open = () => {
|
||||
if (!selectedRow || !selectedRow.dataset.key) {
|
||||
window.alert('请先选择一条记录');
|
||||
return;
|
||||
}
|
||||
const row = JSON.parse(selectedRow.dataset.row || '{}');
|
||||
const recordKey = String(selectedRow.dataset.key).split(':').map(encodeURIComponent).join(':');
|
||||
form.action = base + '/' + recordKey;
|
||||
modal.querySelectorAll('[data-maintenance-field]').forEach(input => {
|
||||
const value = row[input.dataset.maintenanceField];
|
||||
input.value = value == null ? '' : value;
|
||||
});
|
||||
returnFocus = openButton;
|
||||
modal.hidden = false;
|
||||
};
|
||||
rows.forEach(row => {
|
||||
row.addEventListener('click', () => selectRow(row));
|
||||
row.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
selectRow(row);
|
||||
});
|
||||
});
|
||||
openButton?.addEventListener('click', open);
|
||||
modal.querySelectorAll('[data-supplier-maintenance-close]').forEach(node => node.addEventListener('click', close));
|
||||
document.addEventListener('keydown', event => { if (event.key === 'Escape') close(); });
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
@ -0,0 +1,6 @@
|
||||
<el-button
|
||||
size="small"
|
||||
class="supplier-action-button"
|
||||
@click="maintenanceEditSelected">
|
||||
<span class="material-icons-outlined" aria-hidden="true">edit</span><span>编辑</span>
|
||||
</el-button>
|
||||
@ -0,0 +1,96 @@
|
||||
<div class="supplier-maintenance-modal supplier-import-modal" data-supplier-import-modal hidden>
|
||||
<div class="supplier-maintenance-modal__backdrop" data-supplier-import-close></div>
|
||||
<section class="supplier-maintenance-modal__dialog supplier-import-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="supplier-import-modal-title">
|
||||
<header class="supplier-maintenance-modal__header">
|
||||
<h2 id="supplier-import-modal-title">导入数据</h2>
|
||||
<button type="button" data-supplier-import-close aria-label="关闭" title="关闭"><span class="material-icons-outlined">close</span></button>
|
||||
</header>
|
||||
<form method="POST" enctype="multipart/form-data" action="{{ $supplierImportAction }}" data-supplier-import-form>
|
||||
@csrf
|
||||
<div class="supplier-import-dialog">
|
||||
<a href="{{ $supplierTemplateUrl }}" class="supplier-import-template-link">
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span>
|
||||
<span><strong>下载导入模板</strong><small>当前页面字段模板(Excel)</small></span>
|
||||
</a>
|
||||
<input type="file" name="import" accept=".xlsx,.xls,.csv" data-supplier-import-file hidden required>
|
||||
<button type="button" class="supplier-import-file-button" data-supplier-import-choose>
|
||||
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
|
||||
<span data-supplier-import-file-name>选择导入文件</span>
|
||||
</button>
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" class="supplier-maintenance-button" data-supplier-import-close>取消</button>
|
||||
<button type="submit" class="supplier-maintenance-button is-primary" data-supplier-import-submit disabled>开始导入</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@once
|
||||
<style>
|
||||
.supplier-import-modal[hidden] { display: none; }
|
||||
.supplier-import-modal { position: fixed; inset: 0; z-index: 1060; display: grid; place-items: center; padding: 1rem; }
|
||||
.supplier-import-modal .supplier-maintenance-modal__backdrop { position: absolute; inset: 0; background: rgba(5, 5, 6, .58); }
|
||||
.supplier-import-modal__dialog { position: relative; width: min(520px, 94vw); overflow: hidden; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; box-shadow: 0 4px 8px rgba(23, 23, 23, .10); }
|
||||
.supplier-import-modal .supplier-maintenance-modal__header { display: flex; align-items: center; justify-content: space-between; padding: 1rem; border-bottom: 1px solid #D8D2C7; }
|
||||
.supplier-import-modal .supplier-maintenance-modal__header h2 { margin: 0; color: #171717; font-size: .875rem; font-weight: 600; }
|
||||
.supplier-import-modal .supplier-maintenance-modal__header button { border: 0; background: transparent; color: #595959; cursor: pointer; }
|
||||
.supplier-import-modal .supplier-import-dialog { padding: 1rem; }
|
||||
.supplier-import-modal footer { display: flex; justify-content: flex-end; gap: .5rem; padding: 1rem; border-top: 1px solid #D8D2C7; }
|
||||
.supplier-import-modal .supplier-maintenance-button { height: 34px; padding: 0 14px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font-size: .8125rem; font-weight: 500; cursor: pointer; }
|
||||
.supplier-import-modal .supplier-maintenance-button.is-primary { border-color: #856632; background: #856632; color: #fff; }
|
||||
.supplier-import-modal [data-supplier-import-submit]:disabled { cursor: not-allowed; opacity: .5; }
|
||||
</style>
|
||||
@endonce
|
||||
|
||||
@push('body_end')
|
||||
<script>
|
||||
(() => {
|
||||
const modal = document.querySelector('[data-supplier-import-modal]');
|
||||
if (!modal || modal.dataset.ready) return;
|
||||
modal.dataset.ready = '1';
|
||||
const form = modal.querySelector('[data-supplier-import-form]');
|
||||
const input = modal.querySelector('[data-supplier-import-file]');
|
||||
const fileName = modal.querySelector('[data-supplier-import-file-name]');
|
||||
const submit = modal.querySelector('[data-supplier-import-submit]');
|
||||
const openButton = document.querySelector('[data-supplier-import-open]');
|
||||
let returnFocus = null;
|
||||
const reset = () => {
|
||||
form.reset();
|
||||
fileName.textContent = '选择导入文件';
|
||||
submit.disabled = true;
|
||||
submit.textContent = '开始导入';
|
||||
delete form.dataset.submitting;
|
||||
};
|
||||
const close = () => {
|
||||
if (form.dataset.submitting) return;
|
||||
modal.hidden = true;
|
||||
reset();
|
||||
returnFocus?.focus();
|
||||
};
|
||||
openButton?.addEventListener('click', () => {
|
||||
returnFocus = openButton;
|
||||
modal.hidden = false;
|
||||
});
|
||||
modal.querySelector('[data-supplier-import-choose]')?.addEventListener('click', () => input.click());
|
||||
input?.addEventListener('change', () => {
|
||||
const file = input.files?.[0];
|
||||
fileName.textContent = file?.name || '选择导入文件';
|
||||
submit.disabled = !file;
|
||||
});
|
||||
form?.addEventListener('submit', event => {
|
||||
if (!input.files?.length) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
form.dataset.submitting = '1';
|
||||
submit.disabled = true;
|
||||
submit.textContent = '导入中...';
|
||||
});
|
||||
modal.querySelectorAll('[data-supplier-import-close]').forEach(node => node.addEventListener('click', close));
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !modal.hidden) close();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
@ -0,0 +1,43 @@
|
||||
<el-button size="small" class="supplier-action-button" :loading="maintenance.importing" @click="maintenanceOpenImport">
|
||||
<span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>导入</span>
|
||||
</el-button>
|
||||
<el-button size="small" class="supplier-action-button" @click="maintenanceExport">
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span>
|
||||
</el-button>
|
||||
|
||||
<el-dialog title="导入数据" :visible.sync="maintenance.importVisible" width="min(520px, 92vw)" append-to-body :close-on-click-modal="!maintenance.importing" @closed="maintenanceCloseImport">
|
||||
<div class="supplier-import-dialog">
|
||||
<a :href="maintenanceBaseUrl + '/template'" class="supplier-import-template-link">
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span>
|
||||
<span><strong>下载导入模板</strong><small>当前页面字段模板(Excel)</small></span>
|
||||
</a>
|
||||
<input ref="maintenanceImport" type="file" accept=".xlsx,.xls,.csv" class="supplier-maintenance-file" hidden @change="maintenanceSelectImport">
|
||||
<button type="button" class="supplier-import-file-button" @click="maintenanceChooseImport">
|
||||
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
|
||||
<span>@{{ maintenance.importFileName || '选择导入文件' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<span slot="footer">
|
||||
<el-button :disabled="maintenance.importing" @click="maintenanceCloseImport">取消</el-button>
|
||||
<el-button type="primary" :disabled="!maintenance.importFile" :loading="maintenance.importing" @click="maintenanceImport">开始导入</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-if="maintenance.row" title="编辑记录" :visible.sync="maintenance.visible" width="min(760px, 92vw)" append-to-body @closed="maintenanceClose">
|
||||
<el-form label-position="top" class="supplier-maintenance-form">
|
||||
<el-form-item v-for="field in maintenance.fields" :key="field.name" :label="field.label">
|
||||
<el-date-picker
|
||||
v-if="field.type === 'date' || field.type === 'month'"
|
||||
v-model="maintenance.form[field.name]"
|
||||
:type="field.type"
|
||||
:value-format="field.type === 'month' ? 'yyyy-MM' : 'yyyy-MM-dd'"
|
||||
style="width: 100%">
|
||||
</el-date-picker>
|
||||
<el-input v-else v-model="maintenance.form[field.name]" :type="field.type === 'number' ? 'number' : 'text'"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<span slot="footer">
|
||||
<el-button @click="maintenanceClose">取消</el-button>
|
||||
<el-button type="primary" :loading="maintenance.saving" @click="maintenanceSave">保存</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
@ -33,5 +33,106 @@
|
||||
.supplier-page-navigation { display:flex; flex-wrap:wrap; gap:4px 16px; margin-bottom:16px; }
|
||||
.supplier-page-navigation .business-tab-link { flex:0 0 auto; padding:10px 0; font-size:0.75rem; color:var(--vip-content-muted, #595959); }
|
||||
.supplier-page-navigation .business-tab-link.is-active { color:var(--vip-content-accent-dark, #856632); }
|
||||
.supplier-maintenance-file { display: none; }
|
||||
.supplier-maintenance-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 1rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
.supplier-maintenance-form .el-form-item { margin-bottom: 0.75rem; }
|
||||
.supplier-maintenance-form .el-form-item__label { padding: 0; color: #595959; font-size: 0.75rem; }
|
||||
.supplier-maintenance-form input { letter-spacing: 0; }
|
||||
.supplier-maintenance-form input:focus { border-color: #856632; box-shadow: 0 0 0 3px rgba(133, 102, 50, .20); }
|
||||
.supplier-maintenance-form + * .el-button--primary,
|
||||
.el-dialog__footer .el-button--primary { border-color: #856632; background: #856632; }
|
||||
.supplier-import-dialog { display: grid; gap: 12px; }
|
||||
.supplier-import-template-link,
|
||||
.supplier-import-file-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #D8D2C7;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #171717;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.supplier-import-template-link:hover,
|
||||
.supplier-import-template-link:focus,
|
||||
.supplier-import-file-button:hover,
|
||||
.supplier-import-file-button:focus { border-color: #856632; background: #F7F0DE; color: #856632; outline: 0; }
|
||||
.supplier-import-template-link .material-icons-outlined,
|
||||
.supplier-import-file-button .material-icons-outlined { flex: 0 0 auto; font-size: 22px; }
|
||||
.supplier-import-template-link > span:last-child { display: grid; gap: 3px; }
|
||||
.supplier-import-template-link strong { font-size: .875rem; font-weight: 600; }
|
||||
.supplier-import-template-link small { color: #737373; font-size: .75rem; }
|
||||
.supplier-import-file-button > span:last-child { min-width: 0; overflow: hidden; font-size: .8125rem; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.supplier-page-actions {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.supplier-page-actions .supplier-action-button {
|
||||
display: inline-flex;
|
||||
box-sizing: border-box;
|
||||
min-width: 82px;
|
||||
height: 36px;
|
||||
margin: 0;
|
||||
padding: 0 14px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--vip-border, #D8D2C7);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #171717;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.supplier-page-actions .supplier-action-button > span,
|
||||
.supplier-page-actions .supplier-action-button.el-button > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.supplier-page-actions .supplier-action-button .material-icons-outlined {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.supplier-page-actions .supplier-action-button:hover,
|
||||
.supplier-page-actions .supplier-action-button:focus {
|
||||
border-color: var(--vip-content-accent-dark, #856632);
|
||||
background: var(--vip-content-accent-soft, #F7F0DE);
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
outline: 0;
|
||||
}
|
||||
.supplier-page-actions .supplier-action-button.is-primary {
|
||||
border-color: var(--vip-content-accent-dark, #856632);
|
||||
background: var(--vip-content-accent-dark, #856632);
|
||||
color: #fff;
|
||||
}
|
||||
.supplier-page-actions .supplier-action-button.is-primary:hover,
|
||||
.supplier-page-actions .supplier-action-button.is-primary:focus {
|
||||
border-color: #6f5428;
|
||||
background: #6f5428;
|
||||
color: #fff;
|
||||
}
|
||||
@media (max-width: 640px) { .supplier-maintenance-form { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@include('business.pages.supplier-payables.local-navigation')
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<div id="other-adjustment-app" class="other-adjustment-page">
|
||||
<div v-cloak>
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">补款总金额</div>
|
||||
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
|
||||
@ -20,8 +20,7 @@
|
||||
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
|
||||
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
@include('business.pages.supplier-payables.maintenance-actions')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -30,7 +29,7 @@
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
|
||||
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
|
||||
<el-table-column prop="project" label="项目" width="140" fixed="left">
|
||||
<template slot-scope="scope">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<div id="goods-reconciliation-app" class="goods-reconciliation-page">
|
||||
<div v-cloak>
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">对账总金额</div>
|
||||
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
|
||||
@ -53,6 +53,7 @@
|
||||
<el-option label="已发出" value="sent"></el-option>
|
||||
<el-option label="未发出" value="unsent"></el-option>
|
||||
</el-select>
|
||||
@include('business.pages.supplier-payables.maintenance-actions')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -71,7 +72,7 @@
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small"
|
||||
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small"
|
||||
:default-sort="{prop: 'createTime', order: 'descending'}"
|
||||
@sort-change="onSortChange">
|
||||
<el-table-column type="index" label="#" width="55" fixed="left"></el-table-column>
|
||||
|
||||
@ -94,8 +94,7 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-price-actions .material-icons-outlined,
|
||||
.product-price-edit-button .material-icons-outlined {
|
||||
.product-price-actions .material-icons-outlined {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@ -108,24 +107,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-price-edit-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--vip-border, #D8D2C7);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.product-price-edit-button:hover {
|
||||
border-color: var(--vip-content-accent-dark, #856632);
|
||||
background: var(--vip-content-accent-soft, #F7F0DE);
|
||||
}
|
||||
|
||||
.product-price-maintained {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
@ -211,6 +192,16 @@
|
||||
background: var(--vip-content-accent-soft, #F7F0DE);
|
||||
}
|
||||
|
||||
.data-table tbody tr[data-product-price-row] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-table tbody tr.product-price-row-selected {
|
||||
background: var(--vip-content-accent-soft, #F7F0DE);
|
||||
outline: 2px solid var(--vip-content-accent-dark, #856632);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.data-table tbody td {
|
||||
padding: 10px 12px;
|
||||
color: #171717;
|
||||
@ -442,50 +433,41 @@
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label class="filter-label"> </label>
|
||||
<div class="product-price-actions">
|
||||
<label for="product-price-import" class="btn-filter btn-filter-secondary" role="button" tabindex="0">
|
||||
<div class="product-price-actions supplier-page-actions">
|
||||
<button type="button" class="btn-filter btn-filter-secondary supplier-action-button" data-supplier-import-open>
|
||||
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
|
||||
导入
|
||||
</label>
|
||||
</button>
|
||||
<a
|
||||
href="{{ route('business.supplier-payables.product-prices.export', array_merge(['company_id' => company_id()], request()->except('page'))) }}"
|
||||
class="btn-filter btn-filter-secondary"
|
||||
class="btn-filter btn-filter-secondary supplier-action-button"
|
||||
aria-label="导出当前筛选结果"
|
||||
title="导出当前筛选结果"
|
||||
>
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span>
|
||||
导出
|
||||
</a>
|
||||
<button type="submit" class="btn-filter btn-filter-primary">
|
||||
<button type="submit" class="btn-filter btn-filter-primary supplier-action-button is-primary">
|
||||
<span class="material-icons-outlined" aria-hidden="true">search</span>
|
||||
查询
|
||||
</button>
|
||||
<a href="{{ route('business.pages.show', ['company_id' => company_id(), 'section' => $section['slug'], 'page' => $page['slug']]) }}"
|
||||
class="btn-filter btn-filter-secondary">
|
||||
class="btn-filter btn-filter-secondary supplier-action-button">
|
||||
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
|
||||
重置
|
||||
</a>
|
||||
<button type="button" class="btn-filter btn-filter-secondary supplier-action-button" data-product-price-edit-open>
|
||||
<span class="material-icons-outlined" aria-hidden="true">edit</span>
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
id="product-price-import-form"
|
||||
method="POST"
|
||||
action="{{ route('business.supplier-payables.product-prices.import', ['company_id' => company_id()]) }}{{ request()->getQueryString() ? '?' . request()->getQueryString() : '' }}"
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
@csrf
|
||||
<input
|
||||
id="product-price-import"
|
||||
class="product-price-import-input"
|
||||
type="file"
|
||||
name="import"
|
||||
form="product-price-import-form"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
required
|
||||
>
|
||||
</form>
|
||||
@include('business.pages.supplier-payables.maintenance-import-modal', [
|
||||
'supplierImportAction' => route('business.supplier-payables.product-prices.import', ['company_id' => company_id()]) . (request()->getQueryString() ? '?' . request()->getQueryString() : ''),
|
||||
'supplierTemplateUrl' => route('business.supplier-payables.product-prices.template', ['company_id' => company_id()]),
|
||||
])
|
||||
|
||||
@if ($errors->any() && !$errors->has('import'))
|
||||
<div class="product-price-error" role="alert">
|
||||
@ -521,14 +503,32 @@
|
||||
<th>审批结果</th>
|
||||
<th>流程标题</th>
|
||||
<th>流程完成时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($productPrices ?? [] as $index => $item)
|
||||
<tr>
|
||||
<tr
|
||||
data-product-price-row
|
||||
data-action="{{ !empty($item->upstream_key) ? route('business.supplier-payables.product-prices.update', ['company_id' => company_id(), 'upstreamKey' => $item->upstream_key]) . (request()->getQueryString() ? '?' . request()->getQueryString() : '') : '' }}"
|
||||
data-barcode="{{ $item->barcode ?? '' }}"
|
||||
data-spu="{{ $item->spu ?? '' }}"
|
||||
data-product-name="{{ $item->product_name ?? '' }}"
|
||||
data-category="{{ $item->category ?? '' }}"
|
||||
data-production-status="{{ $item->production_status ?? '' }}"
|
||||
data-minimum-price="{{ $item->minimum_price ?? '' }}"
|
||||
data-price-limit="{{ $item->price_limit ?? '' }}"
|
||||
data-purchase-price="{{ $item->purchase_price ?? '' }}"
|
||||
data-shipping-fee="{{ $item->shipping_fee ?? '' }}"
|
||||
data-jingxi-limit-price="{{ $item->jingxi_limit_price ?? '' }}"
|
||||
tabindex="0"
|
||||
aria-selected="false">
|
||||
<td class="mono">{{ ($productPrices->currentPage() - 1) * $productPrices->perPage() + $index + 1 }}</td>
|
||||
<td class="mono">{{ $item->barcode ?? '-' }}</td>
|
||||
<td class="mono">
|
||||
{{ $item->barcode ?? '-' }}
|
||||
@if ((int) ($item->is_overridden ?? 0) === 1)
|
||||
<span class="product-price-maintained">已维护</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="mono">{{ $item->spu ?? '-' }}</td>
|
||||
<td>{{ $item->product_name ?? '-' }}</td>
|
||||
<td>{{ $item->category ?? '-' }}</td>
|
||||
@ -554,39 +554,10 @@
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $item->completed_at ?? '-' }}</td>
|
||||
<td>
|
||||
@if (!empty($item->upstream_key))
|
||||
<button
|
||||
type="button"
|
||||
class="product-price-edit-button"
|
||||
aria-label="编辑 {{ $item->barcode ?? '商品价格' }}"
|
||||
title="编辑"
|
||||
data-product-price-edit
|
||||
data-action="{{ route('business.supplier-payables.product-prices.update', ['company_id' => company_id(), 'upstreamKey' => $item->upstream_key]) }}{{ request()->getQueryString() ? '?' . request()->getQueryString() : '' }}"
|
||||
data-barcode="{{ $item->barcode ?? '' }}"
|
||||
data-spu="{{ $item->spu ?? '' }}"
|
||||
data-product-name="{{ $item->product_name ?? '' }}"
|
||||
data-category="{{ $item->category ?? '' }}"
|
||||
data-production-status="{{ $item->production_status ?? '' }}"
|
||||
data-minimum-price="{{ $item->minimum_price ?? '' }}"
|
||||
data-price-limit="{{ $item->price_limit ?? '' }}"
|
||||
data-purchase-price="{{ $item->purchase_price ?? '' }}"
|
||||
data-shipping-fee="{{ $item->shipping_fee ?? '' }}"
|
||||
data-jingxi-limit-price="{{ $item->jingxi_limit_price ?? '' }}"
|
||||
>
|
||||
<span class="material-icons-outlined" aria-hidden="true">edit</span>
|
||||
</button>
|
||||
@if ((int) ($item->is_overridden ?? 0) === 1)
|
||||
<span class="product-price-maintained">已维护</span>
|
||||
@endif
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="16" class="text-center py-12">
|
||||
<td colspan="15" class="text-center py-12">
|
||||
<span class="material-icons-outlined text-4xl mb-2 block">inventory_2</span>
|
||||
@if ($productPriceError)
|
||||
数据读取失败,请稍后重试。
|
||||
@ -714,73 +685,76 @@
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const importInput = document.getElementById('product-price-import');
|
||||
const importForm = document.getElementById('product-price-import-form');
|
||||
const importLabel = document.querySelector('label[for="product-price-import"]');
|
||||
const modal = document.querySelector('[data-product-price-modal]');
|
||||
const editForm = modal?.querySelector('[data-product-price-edit-form]');
|
||||
const editOpen = document.querySelector('[data-product-price-edit-open]');
|
||||
const rows = document.querySelectorAll('[data-product-price-row]');
|
||||
let selectedRow = null;
|
||||
let returnFocus = null;
|
||||
|
||||
importLabel?.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
importInput?.click();
|
||||
}
|
||||
});
|
||||
|
||||
importInput?.addEventListener('change', () => {
|
||||
if (!importInput.files?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm('确认导入并更新表格中的商品价格记录吗?')) {
|
||||
importForm?.submit();
|
||||
} else {
|
||||
importInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
modal.hidden = true;
|
||||
editForm?.reset();
|
||||
if (editForm) editForm.action = '';
|
||||
returnFocus?.focus();
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-product-price-edit]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
if (!modal || !editForm) {
|
||||
return;
|
||||
const selectRow = (row) => {
|
||||
selectedRow?.classList.remove('product-price-row-selected');
|
||||
selectedRow?.setAttribute('aria-selected', 'false');
|
||||
selectedRow = row;
|
||||
selectedRow.classList.add('product-price-row-selected');
|
||||
selectedRow.setAttribute('aria-selected', 'true');
|
||||
};
|
||||
|
||||
const openEditor = () => {
|
||||
if (!selectedRow?.dataset.action) {
|
||||
window.alert('请先选择一条记录');
|
||||
return;
|
||||
}
|
||||
if (!modal || !editForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
returnFocus = editOpen;
|
||||
editForm.action = selectedRow.dataset.action;
|
||||
const values = {
|
||||
barcode: selectedRow.dataset.barcode,
|
||||
spu: selectedRow.dataset.spu,
|
||||
product_name: selectedRow.dataset.productName,
|
||||
category: selectedRow.dataset.category,
|
||||
production_status: selectedRow.dataset.productionStatus,
|
||||
minimum_price: selectedRow.dataset.minimumPrice,
|
||||
price_limit: selectedRow.dataset.priceLimit,
|
||||
purchase_price: selectedRow.dataset.purchasePrice,
|
||||
shipping_fee: selectedRow.dataset.shippingFee,
|
||||
jingxi_limit_price: selectedRow.dataset.jingxiLimitPrice,
|
||||
};
|
||||
|
||||
Object.entries(values).forEach(([name, value]) => {
|
||||
const input = editForm.elements.namedItem(name);
|
||||
if (input) {
|
||||
input.value = value || '';
|
||||
}
|
||||
});
|
||||
|
||||
returnFocus = button;
|
||||
editForm.action = button.dataset.action || '';
|
||||
const values = {
|
||||
barcode: button.dataset.barcode,
|
||||
spu: button.dataset.spu,
|
||||
product_name: button.dataset.productName,
|
||||
category: button.dataset.category,
|
||||
production_status: button.dataset.productionStatus,
|
||||
minimum_price: button.dataset.minimumPrice,
|
||||
price_limit: button.dataset.priceLimit,
|
||||
purchase_price: button.dataset.purchasePrice,
|
||||
shipping_fee: button.dataset.shippingFee,
|
||||
jingxi_limit_price: button.dataset.jingxiLimitPrice,
|
||||
};
|
||||
modal.hidden = false;
|
||||
editForm.elements.namedItem('barcode')?.focus();
|
||||
};
|
||||
|
||||
Object.entries(values).forEach(([name, value]) => {
|
||||
const input = editForm.elements.namedItem(name);
|
||||
if (input) {
|
||||
input.value = value || '';
|
||||
}
|
||||
});
|
||||
|
||||
modal.hidden = false;
|
||||
editForm.elements.namedItem('barcode')?.focus();
|
||||
rows.forEach((row) => {
|
||||
row.addEventListener('click', () => selectRow(row));
|
||||
row.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
selectRow(row);
|
||||
});
|
||||
});
|
||||
editOpen?.addEventListener('click', openEditor);
|
||||
|
||||
modal?.querySelectorAll('[data-product-price-close]').forEach((button) => {
|
||||
button.addEventListener('click', closeModal);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<div id="return-reconciliation-app" class="return-reconciliation-page">
|
||||
<div v-cloak>
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">对账总金额</div>
|
||||
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
|
||||
@ -29,8 +29,7 @@
|
||||
start-placeholder="发货开始" end-placeholder="发货结束" size="small"
|
||||
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
@include('business.pages.supplier-payables.maintenance-actions')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -39,7 +38,7 @@
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
|
||||
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
|
||||
<el-table-column prop="send_time" label="发货时间" width="110" fixed="left" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="supplier" label="明细供应商" width="110" show-overflow-tooltip></el-table-column>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
</div>
|
||||
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">未定价退货金额</div>
|
||||
<div class="text-2xl font-bold text-orange-500">@{{ formatMoney(summary.totalAmount) }}</div>
|
||||
@ -30,8 +30,7 @@
|
||||
start-placeholder="日期开始" end-placeholder="日期结束" size="small"
|
||||
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
@include('business.pages.supplier-payables.maintenance-actions')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -40,7 +39,7 @@
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
|
||||
<el-table-column prop="send_time" label="发货时间" width="110" fixed="left"></el-table-column>
|
||||
<el-table-column prop="supplier" label="明细供应商" width="120" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="barcode" label="条形码" width="160" show-overflow-tooltip></el-table-column>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<div id="sample-deduction-app" class="sample-deduction-page">
|
||||
<div v-cloak>
|
||||
{{-- 统计卡片 --}}
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
|
||||
<div class="text-sm text-gray-500 mb-1">合计扣款金额</div>
|
||||
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
|
||||
@ -27,8 +27,7 @@
|
||||
start-placeholder="开始" end-placeholder="结束" size="small"
|
||||
style="width: 240px" value-format="yyyy-MM-dd" @change="onFilterChange">
|
||||
</el-date-picker>
|
||||
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
|
||||
<el-button size="small" @click="onReset">重置</el-button>
|
||||
@include('business.pages.supplier-payables.maintenance-actions')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -37,7 +36,7 @@
|
||||
<div v-if="loading" class="flex items-center justify-center py-20">
|
||||
<span class="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
|
||||
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
|
||||
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
|
||||
<el-table-column prop="finish_time" label="日期" width="110" fixed="left"></el-table-column>
|
||||
<el-table-column prop="department" label="部门" width="200" show-overflow-tooltip></el-table-column>
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
$supplierPayableError = $content['supplierPayableError'] ?? null;
|
||||
$supplierPayableStatus = data_get($content, 'supplierPayableStatus.X-Caiwu-Data-Status');
|
||||
$money = static fn (mixed $value): string => $value === null ? '-' : number_format((float) $value, 2);
|
||||
$reportActions = url('/' . (company_id() ?: 1) . '/workspace/supplier-payables/actions/supplier-payables-report');
|
||||
@endphp
|
||||
|
||||
<div class="supplier-payables-report-page">
|
||||
@ -54,6 +55,7 @@
|
||||
|
||||
.supplier-payables-report-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@ -213,20 +215,34 @@
|
||||
<label for="supplier-payables-report-status">结算方式</label>
|
||||
<input id="supplier-payables-report-status" name="settlement_method" type="text" value="{{ $supplierPayableFilters['settlement_method'] ?? '' }}" placeholder="请输入结算方式">
|
||||
</div>
|
||||
<div class="supplier-payables-report-actions">
|
||||
<button type="submit" class="supplier-payables-report-button is-primary">
|
||||
<div class="supplier-payables-report-actions supplier-page-actions">
|
||||
<button type="button" class="supplier-payables-report-button supplier-action-button" data-supplier-import-open>
|
||||
<span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>导入</span>
|
||||
</button>
|
||||
<a href="{{ $reportActions . '/export?' . http_build_query(request()->except('page')) }}" class="supplier-payables-report-button supplier-action-button">
|
||||
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span>
|
||||
</a>
|
||||
<button type="submit" class="supplier-payables-report-button supplier-action-button is-primary">
|
||||
<span class="material-icons-outlined" aria-hidden="true">search</span>
|
||||
<span>查询</span>
|
||||
</button>
|
||||
<a
|
||||
href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"
|
||||
class="supplier-payables-report-button"
|
||||
class="supplier-payables-report-button supplier-action-button"
|
||||
>
|
||||
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
|
||||
<span>重置</span>
|
||||
</a>
|
||||
<button type="button" class="supplier-payables-report-button supplier-action-button" data-supplier-maintenance-open>
|
||||
<span class="material-icons-outlined" aria-hidden="true">edit</span>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@include('business.pages.supplier-payables.maintenance-import-modal', [
|
||||
'supplierImportAction' => $reportActions . '/import',
|
||||
'supplierTemplateUrl' => $reportActions . '/template',
|
||||
])
|
||||
|
||||
@if ($supplierPayableError)
|
||||
<div class="supplier-payables-report-alert" role="alert">{{ $supplierPayableError }}</div>
|
||||
@ -243,7 +259,7 @@
|
||||
<col style="width: 9rem">
|
||||
<col style="width: 8rem">
|
||||
<col style="width: 8rem">
|
||||
<col style="width: 12rem">
|
||||
<col style="width: 5rem">
|
||||
<col style="width: 8rem">
|
||||
<col style="width: 8rem">
|
||||
<col style="width: 8rem">
|
||||
@ -318,7 +334,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($supplierPayables ?? [] as $index => $row)
|
||||
<tr>
|
||||
<tr data-supplier-maintenance-row data-key="{{ $row->_action_key }}" data-row="{!! e(json_encode($row, JSON_UNESCAPED_UNICODE)) !!}" tabindex="0" aria-selected="false">
|
||||
<td>{{ $row->checker ?: '-' }}</td>
|
||||
<td>{{ $row->settlement_method ?: '-' }}</td>
|
||||
<td>{{ ($supplierPayables->currentPage() - 1) * $supplierPayables->perPage() + $index + 1 }}</td>
|
||||
@ -381,3 +397,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@include('business.pages.supplier-payables.maintenance-blade-modal')
|
||||
|
||||
@ -7,9 +7,11 @@ use App\Http\Controllers\Business\CourierPayableSummaries;
|
||||
use App\Http\Controllers\Business\CourierSurchargeQuotes;
|
||||
use App\Http\Controllers\Business\CourierSurchargeTypes;
|
||||
use App\Http\Controllers\Business\ProductPrices;
|
||||
use App\Http\Controllers\Business\SupplierPayableActions;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\EcommerceFinance\Http\Controllers\AccountBills;
|
||||
use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
|
||||
use Modules\EcommerceFinance\Http\Controllers\PinduoduoAccountBills;
|
||||
use Modules\EcommerceFinance\Http\Controllers\Api\GoodsReconciliations;
|
||||
use Modules\EcommerceFinance\Http\Controllers\Api\SupplierPayables;
|
||||
use Modules\EcommerceFinance\Http\Controllers\ThirdPartyWarehouseStorageSettlementExportController;
|
||||
@ -26,6 +28,12 @@ Route::get('ecommerce-finance/alipay-bills/export', [AlipayBills::class, 'export
|
||||
Route::get('ecommerce-finance/alipay-bills/{bill}', [AlipayBills::class, 'show'])
|
||||
->name('ecommerce-finance.alipay-bills.show');
|
||||
|
||||
Route::get('ecommerce-finance/account-bills/type/pinduoduo', [PinduoduoAccountBills::class, 'index'])
|
||||
->name('ecommerce-finance.pinduoduo-account-bills.index');
|
||||
Route::get('ecommerce-finance/account-bills/type/pinduoduo/filter-options', [PinduoduoAccountBills::class, 'filterOptions'])
|
||||
->name('ecommerce-finance.pinduoduo-account-bills.filter-options');
|
||||
Route::get('ecommerce-finance/account-bills/type/pinduoduo/export', [PinduoduoAccountBills::class, 'export'])
|
||||
->name('ecommerce-finance.pinduoduo-account-bills.export');
|
||||
Route::get('ecommerce-finance/account-bills/type/{bill_type}', [AccountBills::class, 'index'])
|
||||
->name('ecommerce-finance.account-bills.index');
|
||||
Route::get('ecommerce-finance/account-bills/type/{bill_type}/filter-options', [AccountBills::class, 'filterOptions'])
|
||||
@ -138,12 +146,34 @@ Route::prefix('workspace/supplier-payables/product-prices')
|
||||
->middleware('business.page:supplier-payables,product-prices')
|
||||
->group(function () {
|
||||
Route::get('export', [ProductPrices::class, 'export'])->name('export');
|
||||
Route::get('template', [ProductPrices::class, 'template'])->name('template');
|
||||
Route::post('import', [ProductPrices::class, 'import'])->middleware('import')->name('import');
|
||||
Route::patch('{upstreamKey}', [ProductPrices::class, 'update'])
|
||||
->where('upstreamKey', '[a-f0-9]{64}')
|
||||
->name('update');
|
||||
});
|
||||
|
||||
$supplierPayableActionPages = [
|
||||
'payment-reconciliation', 'return-reconciliation', 'invoice-deductions',
|
||||
'compensation-details', 'advance-details', 'sample-deductions',
|
||||
'delivery-unpriced', 'return-unpriced', 'other-adjustments',
|
||||
'dropshipping-express', 'board-deductions', 'supplier-payables-report',
|
||||
];
|
||||
|
||||
foreach ($supplierPayableActionPages as $dataset) {
|
||||
Route::prefix('workspace/supplier-payables/actions/' . $dataset)
|
||||
->middleware('business.page:supplier-payables,' . $dataset)
|
||||
->group(function () use ($dataset) {
|
||||
Route::get('definition', [SupplierPayableActions::class, 'definition'])->defaults('dataset', $dataset);
|
||||
Route::get('export', [SupplierPayableActions::class, 'export'])->defaults('dataset', $dataset);
|
||||
Route::get('template', [SupplierPayableActions::class, 'template'])->defaults('dataset', $dataset);
|
||||
Route::post('import', [SupplierPayableActions::class, 'import'])->middleware('import')->defaults('dataset', $dataset);
|
||||
Route::patch('{recordKey}', [SupplierPayableActions::class, 'update'])
|
||||
->where('recordKey', '[A-Za-z0-9:_-]+')
|
||||
->defaults('dataset', $dataset);
|
||||
});
|
||||
}
|
||||
|
||||
Route::get('workspace/refunds/non-jingxi-refund-data/export', 'Business\Pages@exportNonJingxiRefunds')
|
||||
->middleware('business.page:refunds,non-jingxi-refund-data')
|
||||
->name('business.refunds.non-jingxi-refund-data.export');
|
||||
|
||||
@ -39,6 +39,7 @@ trait CreatesApplication
|
||||
// the shared production mirror.
|
||||
config([
|
||||
'database.connections.business_mirror' => config('database.connections.sqlite'),
|
||||
'database.supplier_payable_maintenance_connection' => 'sqlite',
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
]);
|
||||
$app->make(DatabaseManager::class)->purge('business_mirror');
|
||||
|
||||
@ -4,7 +4,7 @@ namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
|
||||
use RuntimeException;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
@ -20,8 +20,8 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase
|
||||
'end' => '2026-09-10',
|
||||
'keyword' => '260909',
|
||||
];
|
||||
$repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->once()->with(
|
||||
$repository = Mockery::mock(PinduoduoSettlementBillRepository::class);
|
||||
$repository->shouldReceive('paginateAccountBills')->once()->with(
|
||||
$filters, 'occurred_at', 'desc', 2, 20,
|
||||
)->andReturn([
|
||||
'current_page' => 2,
|
||||
@ -42,7 +42,7 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase
|
||||
]],
|
||||
'summary' => ['income' => 91.64, 'expense' => 0.0, 'balance' => 91.64, 'count' => 41],
|
||||
]);
|
||||
$this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository);
|
||||
$this->app->instance(PinduoduoSettlementBillRepository::class, $repository);
|
||||
|
||||
$this->withExceptionHandling()
|
||||
->loginAs()
|
||||
@ -61,19 +61,19 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase
|
||||
public function test_filter_options_and_selected_column_export_use_the_same_mirror(): void
|
||||
{
|
||||
$this->allowPage();
|
||||
$repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class);
|
||||
$repository->shouldReceive('filterOptions')->once()->andReturn([
|
||||
$repository = Mockery::mock(PinduoduoSettlementBillRepository::class);
|
||||
$repository->shouldReceive('accountBillFilterOptions')->once()->andReturn([
|
||||
'shops' => ['拼多多-A店', '拼多多-B店'],
|
||||
'bill_items' => ['交易收入', '技术服务费'],
|
||||
]);
|
||||
$repository->shouldReceive('exportRows')->once()->with(
|
||||
$repository->shouldReceive('exportAccountBills')->once()->with(
|
||||
['store_name' => '拼多多-A店'], 'record_code', 'asc',
|
||||
)->andReturn([[
|
||||
'record_code' => '380425163199',
|
||||
'store_name' => '拼多多-A店',
|
||||
'payload' => ['finance_type' => '交易收入'],
|
||||
]]);
|
||||
$this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository);
|
||||
$this->app->instance(PinduoduoSettlementBillRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->withHeader('X-Requested-With', 'XMLHttpRequest')
|
||||
@ -103,25 +103,25 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase
|
||||
public function test_unready_mirror_returns_a_clear_retryable_response(): void
|
||||
{
|
||||
$this->allowPage();
|
||||
$repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class);
|
||||
$repository->shouldReceive('paginate')->once()->andThrow(
|
||||
new RuntimeException('store_receiving_alipay_bill_mirror_not_ready')
|
||||
$repository = Mockery::mock(PinduoduoSettlementBillRepository::class);
|
||||
$repository->shouldReceive('paginateAccountBills')->once()->andThrow(
|
||||
new RuntimeException('pinduoduo_authoritative_bills_not_ready')
|
||||
);
|
||||
$this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository);
|
||||
$this->app->instance(PinduoduoSettlementBillRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->withHeader('X-Requested-With', 'XMLHttpRequest')
|
||||
->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'pinduoduo']))
|
||||
->assertStatus(503)
|
||||
->assertJsonPath('message', '拼多多账户账单共享业务镜像正在首次同步,完成后将自动显示。');
|
||||
->assertJsonPath('message', '拼多多完整货款账单尚未完成首次导入,当前不会回退到不完整的聚水潭辅助表。');
|
||||
}
|
||||
|
||||
public function test_invalid_date_range_is_rejected_before_reading_the_mirror(): void
|
||||
{
|
||||
$this->allowPage();
|
||||
$repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class);
|
||||
$repository->shouldNotReceive('paginate');
|
||||
$this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository);
|
||||
$repository = Mockery::mock(PinduoduoSettlementBillRepository::class);
|
||||
$repository->shouldNotReceive('paginateAccountBills');
|
||||
$this->app->instance(PinduoduoSettlementBillRepository::class, $repository);
|
||||
|
||||
$this->withExceptionHandling()
|
||||
->loginAs()
|
||||
|
||||
@ -7,6 +7,7 @@ use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\ProductPriceWorkbookImporter;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class ProductPriceActionsTest extends FeatureTestCase
|
||||
@ -161,4 +162,30 @@ class ProductPriceActionsTest extends FeatureTestCase
|
||||
]) . '?barcode=SKU-1&page=1')
|
||||
->assertSessionHas('success', '导入完成:新增维护 2 条,更新维护 3 条。');
|
||||
}
|
||||
|
||||
public function test_template_downloads_an_xlsx_with_product_price_import_headers(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
|
||||
$response = $this->loginAs()->get(route(
|
||||
'business.supplier-payables.product-prices.template',
|
||||
['company_id' => $this->company->id],
|
||||
));
|
||||
|
||||
$response->assertOk()->assertDownload();
|
||||
$path = tempnam(sys_get_temp_dir(), 'product-price-template-');
|
||||
file_put_contents($path, $response->streamedContent());
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($path);
|
||||
$headers = $spreadsheet->getSheet(0)->rangeToArray('A1:K1')[0];
|
||||
$this->assertSame([
|
||||
'记录标识', '商品编码', 'SPU', '商品名称', '类目', '生产状态',
|
||||
'最低售价', '计算限价', '采购价', '运费', '京喜限价',
|
||||
], $headers);
|
||||
$this->assertSame('填写说明', $spreadsheet->getSheet(1)->getTitle());
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
247
tests/Feature/Business/SupplierPayableActionsTest.php
Normal file
247
tests/Feature/Business/SupplierPayableActionsTest.php
Normal file
@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableWorkbookImporter;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class SupplierPayableActionsTest extends FeatureTestCase
|
||||
{
|
||||
protected function grantAdminPermission(): void
|
||||
{
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
}
|
||||
|
||||
public function test_update_stores_company_scoped_override_and_audit_event(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
$response = $this->loginAs()->patchJson(
|
||||
'/' . $this->company->id . '/workspace/supplier-payables/actions/compensation-details/id:88',
|
||||
['values' => ['supplier' => '测试供应商', 'amount' => '128.5678', 'reason' => '质量赔付']],
|
||||
);
|
||||
|
||||
$response->assertOk()->assertJsonPath('message', '保存成功。');
|
||||
$this->assertDatabaseHas('supplier_payable_overrides', [
|
||||
'company_id' => $this->company->id,
|
||||
'dataset' => 'compensation-details',
|
||||
'record_key' => 'id:88',
|
||||
]);
|
||||
$this->assertDatabaseHas('supplier_payable_override_events', [
|
||||
'company_id' => $this->company->id,
|
||||
'dataset' => 'compensation-details',
|
||||
'record_key' => 'id:88',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_accepts_multi_segment_record_key(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
|
||||
$response = $this->loginAs()->patchJson(
|
||||
'/' . $this->company->id . '/workspace/supplier-payables/actions/payment-reconciliation/id:sale:3216852',
|
||||
['values' => ['remark' => '编码路由保存测试']],
|
||||
);
|
||||
|
||||
$response->assertOk()->assertJsonPath('message', '保存成功。');
|
||||
$this->assertDatabaseHas('supplier_payable_overrides', [
|
||||
'company_id' => $this->company->id,
|
||||
'dataset' => 'payment-reconciliation',
|
||||
'record_key' => 'id:sale:3216852',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_decorate_applies_only_matching_company_override(): void
|
||||
{
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$row = ['id' => 9, 'supplier' => '原供应商', 'amount' => 10];
|
||||
$store->save('compensation-details', 'id:9', ['supplier' => '公司一'], (int) $this->company->id, null);
|
||||
$store->save('compensation-details', 'id:9', ['supplier' => '公司二'], (int) $this->company->id + 1, null);
|
||||
|
||||
$decorated = $store->decorate('compensation-details', [$row], (int) $this->company->id);
|
||||
|
||||
$this->assertSame('公司一', $decorated[0]['supplier']);
|
||||
$this->assertTrue($decorated[0]['_overridden']);
|
||||
}
|
||||
|
||||
public function test_numeric_fields_reject_more_than_four_decimal_places(): void
|
||||
{
|
||||
$this->expectException(ValidationException::class);
|
||||
|
||||
app(SupplierPayableMaintenanceRegistry::class)->normalize(
|
||||
'supplier-payables-report',
|
||||
['should_pay' => '12.12345'],
|
||||
);
|
||||
}
|
||||
|
||||
public function test_csv_import_creates_override_and_audit_event(): void
|
||||
{
|
||||
$file = UploadedFile::fake()->createWithContent(
|
||||
'compensation-details.csv',
|
||||
"\xEF\xBB\xBF记录标识,供应商,赔付金额,赔付原因\nid:91,导入供应商,128.5678,导入原因\n",
|
||||
);
|
||||
|
||||
$result = app(SupplierPayableWorkbookImporter::class)->import(
|
||||
'compensation-details',
|
||||
$file,
|
||||
(int) $this->company->id,
|
||||
(int) $this->user->id,
|
||||
);
|
||||
|
||||
$this->assertSame(['created' => 1, 'updated' => 0, 'rows' => 1], $result);
|
||||
$this->assertDatabaseHas('supplier_payable_overrides', [
|
||||
'company_id' => $this->company->id,
|
||||
'dataset' => 'compensation-details',
|
||||
'record_key' => 'id:91',
|
||||
'values' => '{"supplier":"导入供应商","amount":"128.5678","reason":"导入原因"}',
|
||||
]);
|
||||
$this->assertDatabaseHas('supplier_payable_override_events', [
|
||||
'company_id' => $this->company->id,
|
||||
'dataset' => 'compensation-details',
|
||||
'record_key' => 'id:91',
|
||||
'source' => 'import',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_csv_import_rejects_duplicate_record_keys_without_writing(): void
|
||||
{
|
||||
$file = UploadedFile::fake()->createWithContent(
|
||||
'duplicate.csv',
|
||||
"记录标识,供应商,赔付金额,赔付原因\nid:92,供应商一,10,原因一\nid:92,供应商二,20,原因二\n",
|
||||
);
|
||||
|
||||
try {
|
||||
app(SupplierPayableWorkbookImporter::class)->import(
|
||||
'compensation-details',
|
||||
$file,
|
||||
(int) $this->company->id,
|
||||
(int) $this->user->id,
|
||||
);
|
||||
$this->fail('重复记录标识应当拒绝导入。');
|
||||
} catch (ValidationException $exception) {
|
||||
$this->assertSame(['第 3 行“记录标识”重复。'], $exception->errors()['import']);
|
||||
}
|
||||
|
||||
$this->assertDatabaseMissing('supplier_payable_overrides', [
|
||||
'company_id' => $this->company->id,
|
||||
'dataset' => 'compensation-details',
|
||||
'record_key' => 'id:92',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_import_rejects_damaged_workbook(): void
|
||||
{
|
||||
$file = UploadedFile::fake()->createWithContent('damaged.xlsx', 'not a workbook');
|
||||
|
||||
try {
|
||||
app(SupplierPayableWorkbookImporter::class)->import(
|
||||
'compensation-details',
|
||||
$file,
|
||||
(int) $this->company->id,
|
||||
(int) $this->user->id,
|
||||
);
|
||||
$this->fail('损坏的工作簿应当拒绝导入。');
|
||||
} catch (ValidationException $exception) {
|
||||
$this->assertSame(
|
||||
['无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。'],
|
||||
$exception->errors()['import'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_export_downloads_utf8_csv_and_escapes_formula_values(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
Carbon::setTestNow('2026-09-17 12:34:56');
|
||||
|
||||
$repository = Mockery::mock(SupplierPayableRepository::class);
|
||||
$repository->shouldReceive('compensationDetails')
|
||||
->once()
|
||||
->with([], 1, 100)
|
||||
->andReturn([
|
||||
'data' => [[
|
||||
'id' => 500,
|
||||
'month' => '2026-09',
|
||||
'supplier' => '=2+2',
|
||||
'amount' => '18.5000',
|
||||
'reason' => '+SUM(A1:A2)',
|
||||
'remark' => '测试备注',
|
||||
]],
|
||||
'total' => 1,
|
||||
]);
|
||||
$this->app->instance(SupplierPayableRepository::class, $repository);
|
||||
|
||||
$response = $this->loginAs()->get(
|
||||
'/' . $this->company->id . '/workspace/supplier-payables/actions/compensation-details/export',
|
||||
);
|
||||
|
||||
$response->assertOk()->assertDownload('_20260917_123456.csv');
|
||||
$this->assertStringContainsString(
|
||||
"filename*=utf-8''" . rawurlencode('赔偿明细_20260917_123456.csv'),
|
||||
(string) $response->headers->get('content-disposition'),
|
||||
);
|
||||
$content = $response->streamedContent();
|
||||
$this->assertStringStartsWith("\xEF\xBB\xBF", $content);
|
||||
|
||||
$lines = preg_split('/\r\n|\n|\r/', substr($content, 3), -1, PREG_SPLIT_NO_EMPTY);
|
||||
$this->assertSame(
|
||||
['记录标识', '日期', '供应商', '赔付金额', '赔付原因', '备注'],
|
||||
str_getcsv($lines[0]),
|
||||
);
|
||||
$this->assertSame(
|
||||
['id:500', '2026-09', "\t=2+2", '18.5000', "\t+SUM(A1:A2)", '测试备注'],
|
||||
str_getcsv($lines[1]),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_each_supplier_payable_page_downloads_an_xlsx_template_with_its_import_headers(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
$registry = app(SupplierPayableMaintenanceRegistry::class);
|
||||
$datasets = [
|
||||
'payment-reconciliation', 'return-reconciliation', 'invoice-deductions',
|
||||
'compensation-details', 'advance-details', 'sample-deductions',
|
||||
'delivery-unpriced', 'return-unpriced', 'other-adjustments',
|
||||
'dropshipping-express', 'board-deductions', 'supplier-payables-report',
|
||||
];
|
||||
|
||||
foreach ($datasets as $dataset) {
|
||||
$response = $this->loginAs()->get(
|
||||
'/' . $this->company->id . '/workspace/supplier-payables/actions/' . $dataset . '/template',
|
||||
);
|
||||
|
||||
$response->assertOk()->assertDownload();
|
||||
$this->assertSame(
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
$response->headers->get('content-type'),
|
||||
);
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'supplier-payable-template-');
|
||||
file_put_contents($path, $response->streamedContent());
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($path);
|
||||
$headers = $spreadsheet->getSheet(0)->rangeToArray(
|
||||
'A1:' . $spreadsheet->getSheet(0)->getHighestColumn() . '1',
|
||||
)[0];
|
||||
$this->assertSame(
|
||||
array_merge(['记录标识'], array_column($registry->fields($dataset), 'label')),
|
||||
$headers,
|
||||
$dataset,
|
||||
);
|
||||
$this->assertSame('填写说明', $spreadsheet->getSheet(1)->getTitle());
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
} finally {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,70 @@ class SupplierPayablePagesTest extends FeatureTestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function testEverySupplierPayablePageExposesTheFiveRequestedActions(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
$pages = [
|
||||
'payment-reconciliation', 'return-reconciliation', 'invoice-deductions',
|
||||
'compensation-details', 'advance-details', 'sample-deductions',
|
||||
'other-adjustments', 'delivery-unpriced', 'return-unpriced',
|
||||
'dropshipping-express', 'board-deductions', 'supplier-payables-report',
|
||||
];
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$response = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'supplier-payables',
|
||||
'page' => $page,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
foreach (['导入', '导出', '编辑', '查询', '重置'] as $action) {
|
||||
$response->assertSeeText($action);
|
||||
}
|
||||
$response->assertSeeText('下载导入模板');
|
||||
$response->assertSee('/template', false);
|
||||
preg_match_all('/class="[^"]*supplier-action-button/', $response->getContent(), $actionButtons);
|
||||
$this->assertCount(5, $actionButtons[0]);
|
||||
}
|
||||
}
|
||||
|
||||
public function testSupplierPayableEditorOverridesTheGlobalMultipartRequestType(): void
|
||||
{
|
||||
$source = file_get_contents(resource_path('assets/js/views/ecommerce-finance/supplier-payable-maintenance.js'));
|
||||
$dialog = file_get_contents(resource_path('views/business/pages/supplier-payables/maintenance-toolbar.blade.php'));
|
||||
|
||||
$this->assertIsString($source);
|
||||
$this->assertStringContainsString("'Content-Type': 'application/json'", $source);
|
||||
$this->assertStringContainsString('maintenanceEditSelected()', $source);
|
||||
$this->assertStringContainsString('v-if="maintenance.row"', $dialog);
|
||||
}
|
||||
|
||||
public function testVueTablesRequireASelectedRowBeforeToolbarEditing(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
$pages = [
|
||||
'payment-reconciliation', 'return-reconciliation', 'invoice-deductions',
|
||||
'compensation-details', 'advance-details', 'sample-deductions',
|
||||
'other-adjustments', 'delivery-unpriced', 'return-unpriced',
|
||||
'dropshipping-express',
|
||||
];
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$response = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'supplier-payables',
|
||||
'page' => $page,
|
||||
]));
|
||||
$content = $response->getContent();
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringContainsString('@current-change="maintenanceSelect"', $content);
|
||||
$this->assertStringContainsString('@click="maintenanceEditSelected"', $content);
|
||||
$this->assertStringNotContainsString(':disabled="!maintenance.fields.length', $content);
|
||||
$this->assertStringNotContainsString('maintenanceOpen(scope.row)', $content);
|
||||
$this->assertLessThan(strpos($content, '@click="maintenanceEditSelected"'), strpos($content, '重置'));
|
||||
}
|
||||
}
|
||||
|
||||
public function testDeliveryUnpricedPageUsesItsOwnTwentyColumnTable()
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
@ -164,15 +228,20 @@ class SupplierPayablePagesTest extends FeatureTestCase
|
||||
->assertSee('已同意')
|
||||
->assertSeeText('导入')
|
||||
->assertSeeText('导出')
|
||||
->assertSeeText('下载导入模板')
|
||||
->assertSeeText('查询')
|
||||
->assertSeeText('重置')
|
||||
->assertSee('data-product-price-edit', false)
|
||||
->assertSee('data-product-price-edit-open', false)
|
||||
->assertSee('data-product-price-row', false)
|
||||
->assertDontSee('class="product-price-edit-button"', false)
|
||||
->assertSeeText('编辑商品价格')
|
||||
->assertDontSee('是否采购降本');
|
||||
|
||||
preg_match_all('/<th(?:\s|>)/', $response->getContent(), $headers);
|
||||
preg_match_all('/class="[^"]*supplier-action-button/', $response->getContent(), $actionButtons);
|
||||
|
||||
$this->assertCount(16, $headers[0]);
|
||||
$this->assertCount(15, $headers[0]);
|
||||
$this->assertCount(5, $actionButtons[0]);
|
||||
}
|
||||
|
||||
public function testProductPricesPageDoesNotTreatUninitializedMirrorAsEmptyData(): void
|
||||
|
||||
348
tests/Feature/Console/PinduoduoSettlementBillImporterTest.php
Normal file
348
tests/Feature/Console/PinduoduoSettlementBillImporterTest.php
Normal file
@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
|
||||
use Modules\EcommerceFinance\Services\PinduoduoSettlementBillImporter;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PinduoduoSettlementBillImporterTest extends TestCase
|
||||
{
|
||||
/** @var list<string> */
|
||||
private array $files = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
$this->createTables();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_it_imports_current_jushengsuan_headers_keeps_all_accounts_and_is_idempotent(): void
|
||||
{
|
||||
$this->insertActiveRow('OLD-IN-PERIOD', '2026-08-15 12:00:00');
|
||||
$this->insertActiveRow('KEEP-OUTSIDE-PERIOD', '2026-07-31 23:59:59');
|
||||
$path = $this->xlsx([
|
||||
$this->row('REC-INCOME', '2026-08-01 00:00:19', '100.00', '0', '100.00'),
|
||||
$this->row('REC-EXPENSE', '2026-08-02 12:00:00', '0', '25.00', '-25.00', '拼多多货款账户', '退款'),
|
||||
$this->row('REC-ZERO', '2026-08-03 12:00:00', '0', '0', '0'),
|
||||
$this->row('REC-MARKETING', '2026-08-04 12:00:00', '0', '3.00', '-3.00', '拼多多营销账户'),
|
||||
]);
|
||||
|
||||
$importer = app(PinduoduoSettlementBillImporter::class);
|
||||
$result = $importer->import(
|
||||
$path,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
4,
|
||||
'100.00',
|
||||
'28.00',
|
||||
100,
|
||||
);
|
||||
|
||||
$this->assertSame('succeeded', $result['status']);
|
||||
$this->assertSame(4, $result['row_count']);
|
||||
$this->assertSame(0, $result['excluded_account_rows']);
|
||||
$this->assertSame(1, $result['store_count']);
|
||||
$this->assertSame(2, $result['bill_item_count']);
|
||||
$this->assertSame([
|
||||
'拼多多货款账户' => 3,
|
||||
'拼多多营销账户' => 1,
|
||||
], $result['account_type_counts']);
|
||||
$this->assertSame('100.00', $result['income_total']);
|
||||
$this->assertSame('28.00', $result['expense_total']);
|
||||
$this->assertSame('72.00', $result['net_total']);
|
||||
$this->assertDatabaseMissing('pdd_settlement_bill_rows', ['record_code' => 'OLD-IN-PERIOD'], 'business_mirror');
|
||||
$this->assertDatabaseHas('pdd_settlement_bill_rows', ['record_code' => 'KEEP-OUTSIDE-PERIOD'], 'business_mirror');
|
||||
$this->assertDatabaseHas('pdd_settlement_bill_rows', [
|
||||
'record_code' => 'REC-MARKETING',
|
||||
'account_name' => '拼多多营销账户',
|
||||
], 'business_mirror');
|
||||
|
||||
$repository = app(PinduoduoSettlementBillRepository::class);
|
||||
$this->assertTrue($repository->ready());
|
||||
$this->assertSame([
|
||||
'income_total' => 100.0,
|
||||
'expense_total' => 28.0,
|
||||
'cnt' => 4,
|
||||
], $repository->summarize(null, '2026-08-01', '2026-08-31', null));
|
||||
$this->assertSame(1, $repository->paginate(null, '2026-08-01', '2026-08-31', 'income', 1, 25)['total']);
|
||||
$this->assertSame(2, $repository->paginate(null, '2026-08-01', '2026-08-31', 'expense', 1, 25)['total']);
|
||||
$zero = $repository->paginate(null, '2026-08-03', '2026-08-03', null, 1, 25);
|
||||
$this->assertSame(0, $zero['rows'][0]['flowType']);
|
||||
|
||||
$unchanged = $importer->import(
|
||||
$path,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
4,
|
||||
'100.00',
|
||||
'28.00',
|
||||
100,
|
||||
);
|
||||
$this->assertSame('unchanged', $unchanged['status']);
|
||||
$this->assertSame(5, DB::connection('business_mirror')->table('pdd_settlement_bill_rows')->count());
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('本次账期或权威控制数与历史记录不一致');
|
||||
$importer->import($path, '2026-08-01', '2026-08-31', 5, '100.00', '28.00', 100);
|
||||
}
|
||||
|
||||
public function test_control_mismatch_does_not_replace_the_active_period(): void
|
||||
{
|
||||
$this->insertActiveRow('ACTIVE-ROW', '2026-08-15 12:00:00');
|
||||
$path = $this->xlsx([
|
||||
$this->row('NEW-ROW', '2026-08-16 12:00:00', '10.00', '0', '10.00'),
|
||||
]);
|
||||
|
||||
try {
|
||||
app(PinduoduoSettlementBillImporter::class)->import(
|
||||
$path,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
2,
|
||||
'10.00',
|
||||
'0.00',
|
||||
100,
|
||||
);
|
||||
$this->fail('控制数不一致时必须拒绝激活。');
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->assertStringContainsString('权威控制数不一致', $exception->getMessage());
|
||||
}
|
||||
|
||||
$this->assertDatabaseHas('pdd_settlement_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror');
|
||||
$this->assertDatabaseMissing('pdd_settlement_bill_rows', ['record_code' => 'NEW-ROW'], 'business_mirror');
|
||||
$this->assertSame('failed', DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->value('status'));
|
||||
$this->assertSame(2, DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->value('expected_row_count'));
|
||||
$this->assertSame(1, DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->value('row_count'));
|
||||
$this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_staging')->count());
|
||||
}
|
||||
|
||||
public function test_duplicate_record_codes_are_rejected_without_replacing_active_rows(): void
|
||||
{
|
||||
$this->insertActiveRow('ACTIVE-ROW', '2026-08-15 12:00:00');
|
||||
$path = $this->xlsx([
|
||||
$this->row('DUPLICATE', '2026-08-16 12:00:00', '10.00', '0', '10.00'),
|
||||
$this->row('DUPLICATE', '2026-08-17 12:00:00', '20.00', '0', '20.00'),
|
||||
]);
|
||||
|
||||
$failure = null;
|
||||
try {
|
||||
app(PinduoduoSettlementBillImporter::class)->import(
|
||||
$path,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
2,
|
||||
'30.00',
|
||||
'0.00',
|
||||
100,
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
$failure = $exception;
|
||||
}
|
||||
|
||||
$this->assertNotNull($failure, '重复记录编码必须拒绝导入。');
|
||||
$this->assertDatabaseHas('pdd_settlement_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror');
|
||||
$this->assertDatabaseMissing('pdd_settlement_bill_rows', ['record_code' => 'DUPLICATE'], 'business_mirror');
|
||||
$this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_staging')->count());
|
||||
}
|
||||
|
||||
public function test_out_of_period_and_inconsistent_amount_rows_are_rejected(): void
|
||||
{
|
||||
$outOfPeriod = $this->xlsx([
|
||||
$this->row('OUTSIDE', '2026-09-01 00:00:00', '10.00', '0', '10.00'),
|
||||
]);
|
||||
$badAmount = $this->xlsx([
|
||||
$this->row('BAD-AMOUNT', '2026-08-10 00:00:00', '10.00', '2.00', '9.00'),
|
||||
]);
|
||||
$importer = app(PinduoduoSettlementBillImporter::class);
|
||||
|
||||
foreach ([
|
||||
[$outOfPeriod, '发生时间超出指定账期'],
|
||||
[$badAmount, '收入、支出与金额不一致'],
|
||||
] as [$path, $message]) {
|
||||
try {
|
||||
$importer->import($path, '2026-08-01', '2026-08-31', 1, '10.00', '0.00', 100);
|
||||
$this->fail($message . '时必须拒绝导入。');
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->assertStringContainsString($message, $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_rows')->count());
|
||||
$this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_staging')->count());
|
||||
}
|
||||
|
||||
public function test_blank_account_type_is_rejected(): void
|
||||
{
|
||||
$path = $this->xlsx([
|
||||
$this->row('BLANK-ACCOUNT', '2026-08-10 00:00:00', '10.00', '0', '10.00', ''),
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('缺少账户类型');
|
||||
app(PinduoduoSettlementBillImporter::class)->import(
|
||||
$path,
|
||||
'2026-08-01',
|
||||
'2026-08-31',
|
||||
1,
|
||||
'10.00',
|
||||
'0.00',
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
private function createTables(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('pdd_settlement_bill_imports', function (Blueprint $table): void {
|
||||
$table->uuid('generation')->primary();
|
||||
$table->string('source_filename');
|
||||
$table->char('source_sha256', 64)->unique();
|
||||
$table->date('period_start');
|
||||
$table->date('period_end');
|
||||
$table->string('status', 32)->default('running');
|
||||
$table->unsignedBigInteger('expected_row_count');
|
||||
$table->decimal('expected_income_total', 20, 2);
|
||||
$table->decimal('expected_expense_total', 20, 2);
|
||||
$table->unsignedBigInteger('row_count')->default(0);
|
||||
$table->unsignedBigInteger('excluded_account_rows')->default(0);
|
||||
$table->unsignedInteger('store_count')->default(0);
|
||||
$table->unsignedInteger('bill_item_count')->default(0);
|
||||
$table->json('account_type_counts')->nullable();
|
||||
$table->decimal('income_total', 20, 2)->default(0);
|
||||
$table->decimal('expense_total', 20, 2)->default(0);
|
||||
$table->decimal('net_total', 20, 2)->default(0);
|
||||
$table->string('last_error_code', 120)->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
$schema->create('pdd_settlement_bill_staging', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->uuid('generation');
|
||||
$this->billColumns($table);
|
||||
$table->unique(['generation', 'record_code']);
|
||||
});
|
||||
$schema->create('pdd_settlement_bill_rows', function (Blueprint $table): void {
|
||||
$table->string('record_code', 191)->primary();
|
||||
$this->billColumns($table, false);
|
||||
$table->uuid('source_generation');
|
||||
$table->char('source_sha256', 64);
|
||||
$table->timestamp('imported_at');
|
||||
});
|
||||
}
|
||||
|
||||
private function billColumns(Blueprint $table, bool $includeRecordCode = true): void
|
||||
{
|
||||
if ($includeRecordCode) {
|
||||
$table->string('record_code', 191);
|
||||
}
|
||||
$table->string('store_name')->nullable();
|
||||
$table->string('custom_remark')->nullable();
|
||||
$table->string('bill_item')->nullable();
|
||||
$table->string('merchant_order_no', 191)->nullable();
|
||||
$table->string('online_order_no', 191)->nullable();
|
||||
$table->string('business_type')->nullable();
|
||||
$table->string('business_description', 1000)->nullable();
|
||||
$table->string('account_name')->nullable();
|
||||
$table->dateTime('occurred_at');
|
||||
$table->decimal('income_amount', 20, 2)->default(0);
|
||||
$table->decimal('expense_amount', 20, 2)->default(0);
|
||||
$table->decimal('signed_amount', 20, 2)->default(0);
|
||||
$table->string('remark', 1000)->nullable();
|
||||
$table->dateTime('source_updated_at')->nullable();
|
||||
$table->string('source_modified_by')->nullable();
|
||||
$table->unsignedBigInteger('source_row_number');
|
||||
}
|
||||
|
||||
private function insertActiveRow(string $recordCode, string $occurredAt): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('pdd_settlement_bill_rows')->insert([
|
||||
'record_code' => $recordCode,
|
||||
'store_name' => '原有店铺',
|
||||
'bill_item' => '原有流水',
|
||||
'account_name' => PinduoduoSettlementBillImporter::ACCOUNT_NAME,
|
||||
'occurred_at' => $occurredAt,
|
||||
'income_amount' => 1,
|
||||
'expense_amount' => 0,
|
||||
'signed_amount' => 1,
|
||||
'source_row_number' => 1,
|
||||
'source_generation' => 'existing-generation',
|
||||
'source_sha256' => str_repeat('a', 64),
|
||||
'imported_at' => '2026-09-01 00:00:00',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function row(
|
||||
string $recordCode,
|
||||
string $occurredAt,
|
||||
string $income,
|
||||
string $expense,
|
||||
string $signed,
|
||||
string $account = PinduoduoSettlementBillImporter::ACCOUNT_NAME,
|
||||
string $billItem = '技术服务费',
|
||||
): array {
|
||||
return [
|
||||
$recordCode,
|
||||
'拼多多-测试店铺',
|
||||
'',
|
||||
$billItem,
|
||||
'ORDER-' . $recordCode,
|
||||
'ONLINE-' . $recordCode,
|
||||
$billItem,
|
||||
'001|' . $billItem,
|
||||
$account,
|
||||
$occurredAt,
|
||||
$income,
|
||||
$expense,
|
||||
$signed,
|
||||
'-',
|
||||
'2026-09-07 17:07:15',
|
||||
'RPA',
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<list<string>> $rows */
|
||||
private function xlsx(array $rows): string
|
||||
{
|
||||
$temporary = tempnam(sys_get_temp_dir(), 'pdd-authoritative-');
|
||||
$path = $temporary . '.xlsx';
|
||||
rename($temporary, $path);
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$spreadsheet->getActiveSheet()->fromArray(array_merge([[
|
||||
'记录编码', '店铺', '自定义备注', '账单项目', '商户订单号', '线上单号',
|
||||
'财务类型', '业务描述', '类型', '发生时间', '收入金额', '支出金额', '金额',
|
||||
'备注', '修改时间', '修改人',
|
||||
]], $rows));
|
||||
(new Xlsx($spreadsheet))->save($path);
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
$this->files[] = $path;
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PinduoduoSettlementAccountBillsRepositoryTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
$this->createTables();
|
||||
}
|
||||
|
||||
public function test_account_bill_page_reads_authoritative_rows_with_filters_and_totals(): void
|
||||
{
|
||||
$this->insertSucceededImport();
|
||||
$this->insertRow('REC-INCOME', '拼多多一店', '交易收款', '2026-08-01 00:00:00', '100.00', '0.00', '100.00', 'M-100', 'O-100');
|
||||
$this->insertRow('REC-EXPENSE', '拼多多一店', '退款', '2026-08-31 23:59:59', '0.00', '20.00', '-20.00', 'M-200', 'O-200');
|
||||
$this->insertRow('REC-OTHER', '拼多多二店', '交易收款', '2026-08-15 12:00:00', '50.00', '0.00', '50.00', 'M-300', 'O-300');
|
||||
|
||||
$result = app(PinduoduoSettlementBillRepository::class)->paginateAccountBills([
|
||||
'store_name' => '拼多多一店',
|
||||
'start' => '2026-08-01',
|
||||
'end' => '2026-08-31',
|
||||
], 'occurred_at', 'desc', 1, 10);
|
||||
|
||||
$this->assertSame(2, $result['total']);
|
||||
$this->assertSame(100.0, $result['summary']['income']);
|
||||
$this->assertSame(20.0, $result['summary']['expense']);
|
||||
$this->assertSame(80.0, $result['summary']['balance']);
|
||||
$this->assertSame('REC-EXPENSE', $result['data'][0]['record_code']);
|
||||
$this->assertSame('M-200', $result['data'][0]['order_no']);
|
||||
$this->assertSame('O-200', $result['data'][0]['payload']['online_order_no']);
|
||||
$this->assertSame('拼多多货款账户', $result['data'][0]['payload']['type']);
|
||||
$this->assertSame(20.0, $result['data'][0]['amount']);
|
||||
$this->assertSame(-20.0, $result['data'][0]['balance']);
|
||||
|
||||
$filtered = app(PinduoduoSettlementBillRepository::class)->paginateAccountBills([
|
||||
'bill_item' => '退款',
|
||||
'keyword' => 'O-200',
|
||||
], 'record_code', 'asc', 1, 10);
|
||||
|
||||
$this->assertSame(1, $filtered['total']);
|
||||
$this->assertSame('REC-EXPENSE', $filtered['data'][0]['record_code']);
|
||||
|
||||
$options = app(PinduoduoSettlementBillRepository::class)->accountBillFilterOptions();
|
||||
$this->assertSame(['拼多多一店', '拼多多二店'], $options['shops']);
|
||||
$this->assertSame(['交易收款', '退款'], $options['bill_items']);
|
||||
|
||||
$export = iterator_to_array(app(PinduoduoSettlementBillRepository::class)->exportAccountBills([
|
||||
'bill_item' => '退款',
|
||||
], 'record_code', 'asc'));
|
||||
$this->assertCount(1, $export);
|
||||
$this->assertSame('REC-EXPENSE', $export[0]['record_code']);
|
||||
}
|
||||
|
||||
public function test_account_bill_page_fails_closed_until_a_successful_import_exists(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('pinduoduo_authoritative_bills_not_ready');
|
||||
|
||||
app(PinduoduoSettlementBillRepository::class)->paginateAccountBills(
|
||||
[],
|
||||
'occurred_at',
|
||||
'desc',
|
||||
1,
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
private function createTables(): void
|
||||
{
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('pdd_settlement_bill_imports', function (Blueprint $table): void {
|
||||
$table->uuid('generation')->primary();
|
||||
$table->string('status', 32);
|
||||
});
|
||||
$schema->create('pdd_settlement_bill_rows', function (Blueprint $table): void {
|
||||
$table->string('record_code', 191)->primary();
|
||||
$table->string('store_name')->nullable();
|
||||
$table->string('custom_remark')->nullable();
|
||||
$table->string('bill_item')->nullable();
|
||||
$table->string('merchant_order_no', 191)->nullable();
|
||||
$table->string('online_order_no', 191)->nullable();
|
||||
$table->string('business_type')->nullable();
|
||||
$table->string('business_description', 1000)->nullable();
|
||||
$table->string('account_name')->nullable();
|
||||
$table->dateTime('occurred_at');
|
||||
$table->decimal('income_amount', 20, 2);
|
||||
$table->decimal('expense_amount', 20, 2);
|
||||
$table->decimal('signed_amount', 20, 2);
|
||||
$table->string('remark', 1000)->nullable();
|
||||
$table->dateTime('source_updated_at')->nullable();
|
||||
$table->string('source_modified_by')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
private function insertSucceededImport(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->insert([
|
||||
'generation' => '11111111-1111-1111-1111-111111111111',
|
||||
'status' => 'succeeded',
|
||||
]);
|
||||
}
|
||||
|
||||
private function insertRow(
|
||||
string $recordCode,
|
||||
string $storeName,
|
||||
string $billItem,
|
||||
string $occurredAt,
|
||||
string $income,
|
||||
string $expense,
|
||||
string $signed,
|
||||
string $merchantOrderNo,
|
||||
string $onlineOrderNo,
|
||||
): void {
|
||||
DB::connection('business_mirror')->table('pdd_settlement_bill_rows')->insert([
|
||||
'record_code' => $recordCode,
|
||||
'store_name' => $storeName,
|
||||
'custom_remark' => '自定义备注',
|
||||
'bill_item' => $billItem,
|
||||
'merchant_order_no' => $merchantOrderNo,
|
||||
'online_order_no' => $onlineOrderNo,
|
||||
'business_type' => '货款',
|
||||
'business_description' => '业务描述',
|
||||
'account_name' => '拼多多货款账户',
|
||||
'occurred_at' => $occurredAt,
|
||||
'income_amount' => $income,
|
||||
'expense_amount' => $expense,
|
||||
'signed_amount' => $signed,
|
||||
'remark' => '账单备注',
|
||||
'source_updated_at' => '2026-09-01 08:00:00',
|
||||
'source_modified_by' => '聚胜算',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user