2628 lines
112 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\Business;
use App\Business\BusinessTableImporter;
use App\Business\TableRegistry;
use App\Business\UpstreamBusinessInformation;
use App\Business\StoreReceivingAccountTemplate;
use App\Business\StoreReceivingAccountWorkbookImporter;
use App\Models\Banking\MarketingAccountBill;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierSigningSubject;
use App\Models\Business\InstallationDetail;
use App\Models\StoreReceivables\StoreZhongcaiReconciliation;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Database\QueryException;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\JdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository;
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository;
use Modules\EcommerceFinance\Repositories\DingPayableWorkflowMirrorRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\View\PlatformPromotionAccountPageData;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Throwable;
class Pages extends Controller
{
/** @var list<string> 快递签署页当前可选的合作方名称 */
private const AGREEMENT_COURIER_NAMES = [
'新北中通',
'新北中通其他',
'新德邦快递',
'睢宁中通',
'武进极兔',
'沭阳德邦',
'常州圆通其他',
];
private const EDGE_BANDING_SOURCE = 'jiansun';
private const INVENTORY_SOURCE = 'jiansun';
private const RAW_BOARD_SOURCE = 'jiansun';
public function __construct(
private readonly EdgeBandingRepository $edgeBanding,
private readonly InventoryRepository $inventory,
private readonly InstallationDetailMirrorRepository $installationDetails,
private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds,
private readonly ProductCatalogMirrorRepository $productCatalog,
private readonly ProductPriceMirrorRepository $productPrices,
private readonly CourierInformationMirrorRepository $courierInformation,
private readonly RawBoardPayablesRepository $rawBoardPayables,
private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows,
private readonly SupplierPayableReportRepository $supplierPayableReports,
private readonly UpstreamQueryCacheFactory $upstreamCaches,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
/**
* 支持的营销账户平台列表
*/
protected const MARKETING_PLATFORMS = [
'taobao-alipay', 'taobao-wechat', 'pinduoduo',
'jd-wallet', 'douyin', 'dewu', 'vipshop',
];
public function home()
{
$section = collect(SidebarPermissions::navigationFor(user()))->first();
abort_if(empty($section), 403, '当前账号没有已授权的侧边栏功能。');
$page = collect($section['pages'] ?? [])->first();
$parameters = ['section' => $section['slug']];
if ($page) {
$parameters['page'] = $page['slug'];
if (! empty($page['tabs'])) {
$parameters['tab'] = $page['tabs'][0]['slug'];
}
}
return redirect()->route('business.pages.show', $parameters);
}
public function show(string $section, ?string $page = null, ?string $tab = null)
{
$sectionConfig = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', $section);
abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。');
$pages = collect($sectionConfig['pages'] ?? []);
if ($pages->isEmpty()) {
abort_if($page !== null || $tab !== null, 404);
return view('business.pages.show', $this->viewData($sectionConfig));
}
if ($page === null) {
return $this->redirectToPage($sectionConfig, $pages->first());
}
$pageConfig = $pages
->flatMap(fn (array $configuredPage) => [
$configuredPage,
...($configuredPage['children'] ?? []),
])
->firstWhere('slug', $page);
abort_if(empty($pageConfig), 404);
if (! empty($pageConfig['children']) && empty($pageConfig['tabs'])) {
return $this->redirectToPage($sectionConfig, $pageConfig['children'][0]);
}
$tabs = collect($pageConfig['tabs'] ?? []);
if ($tabs->isEmpty()) {
// Old bookmarked tab URLs should land on the now-empty parent
// directory while the receiving-account pages are being rebuilt.
if ($tab !== null) {
return redirect()->route('business.pages.show', [
'section' => $sectionConfig['slug'],
'page' => $pageConfig['slug'],
]);
}
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
$tabs,
null,
$this->resolveContent($page, null),
));
}
if ($tab === null) {
return $this->redirectToPage($sectionConfig, $pageConfig);
}
$activeTab = $tabs->firstWhere('slug', $tab);
abort_if(empty($activeTab), 404);
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
$tabs,
$activeTab,
$this->resolveContent($page, $tab),
));
}
public function installationDetails(string $guid)
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。');
$payload = $this->installationDetails->details($guid);
if (! $payload['ready']) {
return response()->json(['message' => '安装明细镜像尚未完成首次同步'], 503);
}
return response()->json(['data' => $payload['data'], 'has_more' => $payload['has_more'] ?? false]);
}
public function exportInstallationDetails(Request $request): StreamedResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'installation-details'), 403, '当前账号没有该页面权限。');
$filters = InstallationDetailMirrorRepository::requestFilters();
$firstPage = $this->installationDetails->paginate(1, 100, $filters);
abort_if(! $firstPage['ready'], 503, '安装明细镜像尚未完成首次同步');
$labels = InstallationDetail::columnLabels();
return response()->streamDownload(function () use ($filters, $firstPage, $labels): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建安装明细导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_values($labels));
$page = 1;
$result = $firstPage;
do {
foreach ($result['data'] as $row) {
fputcsv($csv, array_map(
fn (string $name): string => $this->csvText($row[$name] ?? ''),
array_keys($labels),
));
}
$page++;
$hasMore = ($page - 1) * 100 < $result['total'];
if ($hasMore) {
$result = $this->installationDetails->paginate($page, 100, $filters);
}
} while ($hasMore);
fclose($csv);
}, '安装明细_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
protected function viewData(
array $section,
?array $page = null,
$tabs = null,
?array $activeTab = null,
?array $content = null,
): array {
$descriptor = null;
$rows = collect();
$tabValue = null;
$tabColumn = null;
$upstreamError = null;
if ($page && $content === null) {
$descriptor = TableRegistry::describe(
$section['slug'],
$page['slug'],
$activeTab['slug'] ?? null,
);
if ($descriptor) {
if ($descriptor->readOnly) {
try {
$rows = app(UpstreamBusinessInformation::class)->paginate(
$section['slug'],
$page['slug'],
50,
);
} catch (ServiceUnavailableHttpException $exception) {
// 首份快照尚未生成或某个来源暂时不可用时,保留页面、
// 交代原因并允许后台刷新,而不是把整个业务页变成通用 503。
$rows = new LengthAwarePaginator([], 0, 50, 1, [
'path' => request()->url(),
'query' => request()->query(),
]);
$upstreamError = $exception->getMessage();
}
return [
'section' => $section,
'page' => $page,
'tabs' => $tabs ?? collect(),
'activeTab' => $activeTab,
'content' => $content,
'descriptor' => $descriptor,
'rows' => $rows,
'upstreamError' => $upstreamError,
'tabColumn' => null,
'tabValue' => null,
'edgeBanding' => $this->edgeBandingContent($page['slug']),
'inventoryData' => $this->inventoryContent($page['slug']),
'rawBoardData' => $this->rawBoardContent($page['slug']),
'agreementTasks' => $this->agreementTasks($page['slug']),
'agreementSubjects' => $this->agreementSubjects($page['slug']),
'agreementCourierAccounts' => $this->agreementCourierAccounts($page['slug']),
];
}
if ($this->isStoreReceivingAccountPage($section['slug'], $page['slug'])) {
try {
app(\App\Business\StoreReceivingAccountSynchronizer::class)->sync((int) (company_id() ?: 1));
} catch (Throwable $exception) {
report($exception);
$upstreamError = '店铺基础信息暂时无法同步,请稍后重试。';
}
$rows = $this->storeReceivingAccountRows($activeTab['slug'] ?? null);
$tabColumn = 'store_platform';
} else {
$query = ($descriptor->model)::query();
if ($descriptor->tabFilterColumn && $activeTab) {
$tabValue = TableRegistry::tabValue($section['slug'], $page['slug'], $activeTab['slug']);
if ($tabValue !== null) {
$query->where($descriptor->tabFilterColumn, $tabValue);
}
$tabColumn = $descriptor->tabFilterColumn;
}
foreach (TableRegistry::fixedFilters($section['slug'], $page['slug']) as $column => $value) {
$query->where($column, $value);
}
$rows = $query->orderByDesc('id')->paginate(50)->withQueryString();
}
}
}
return [
'section' => $section,
'page' => $page,
'tabs' => $tabs ?? collect(),
'activeTab' => $activeTab,
'content' => $content,
'descriptor' => $descriptor,
'rows' => $rows,
'upstreamError' => $upstreamError,
'tabColumn' => $tabColumn,
'tabValue' => $tabValue,
'edgeBanding' => $page ? $this->edgeBandingContent($page['slug']) : null,
'inventoryData' => $page ? $this->inventoryContent($page['slug']) : null,
'rawBoardData' => $page ? $this->rawBoardContent($page['slug']) : null,
'agreementTasks' => $this->agreementTasks($page['slug'] ?? null),
'agreementSubjects' => $this->agreementSubjects($page['slug'] ?? null),
'agreementCourierAccounts' => $this->agreementCourierAccounts($page['slug'] ?? null),
];
}
private function isStoreReceivingAccountPage(string $section, string $page): bool
{
return $section === 'fund-management' && $page === 'store-receiving-accounts';
}
private function storeReceivingAccountRows(?string $tab): LengthAwarePaginator
{
$query = \App\Models\Business\StoreReceivingAccount::query();
$platforms = $tab === null
? ['淘宝', '阿里巴巴', '天猫', '淘宝分销', '唯品会', '京东', '京喜', '抖音', '拼多多']
: TableRegistry::tabValues('fund-management', 'store-receiving-accounts', $tab);
if ($platforms !== []) {
$query->whereIn('store_platform', $platforms);
}
$erpName = mb_substr(trim((string) request('erp_store_name', '')), 0, 191);
if ($erpName !== '') {
$query->where('store_name', 'like', '%' . addcslashes($erpName, '%_\\') . '%');
}
$rows = $query->orderByDesc('id')->get();
$shopName = mb_substr(trim((string) request('shop_name', '')), 0, 191);
$status = trim((string) request('store_status', ''));
if ($shopName !== '' || $status !== '') {
$rows = $rows->filter(function ($row) use ($shopName, $status): bool {
return ($shopName === '' || str_contains((string) $row->shop_name, $shopName))
&& ($status === '' || (string) $row->store_status === $status);
})->values();
}
$page = max((int) request('page', 1), 1);
$perPage = 50;
$total = $rows->count();
return new LengthAwarePaginator(
$rows->forPage($page, $perPage)->values(),
$total,
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()],
);
}
/** @return array<string,string> */
protected function agreementCourierAccounts(?string $page): array
{
if ($page !== 'agreement-signing') {
return [];
}
try {
return $this->courierInformation->signingAccountsByName(self::AGREEMENT_COURIER_NAMES);
} catch (Throwable $exception) {
// 快递镜像不可用时仍可手工填写 E 签宝账号,不能阻断签署页。
report($exception);
return [];
}
}
protected function agreementTasks(?string $page): Collection
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
return CourierAgreementSignature::query()
->latest('id')
->limit(50)
->get();
} catch (QueryException $exception) {
// 迁移尚未执行时仍允许打开页面;发送接口会明确提示数据库未就绪。
report($exception);
return collect();
}
}
protected function agreementSubjects(?string $page): Collection
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
return CourierSigningSubject::query()
->orderByDesc('is_default')
->orderBy('name')
->get();
} catch (QueryException $exception) {
// 主体管理迁移尚未执行时仍允许打开签署页面,默认使用当前公司名称。
report($exception);
return collect();
}
}
protected function resolveContent(string $page, ?string $tab): ?array
{
return match ($page) {
'marketing-account-bills' => $this->contentForMarketingAccountBills($tab),
'marketing-account-balance' => $this->contentForMarketingAccountBalance($tab),
'non-jingxi-refund-data' => $this->contentForNonJingxiRefundData(),
'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(),
'normal-products' => $this->contentForNormalProducts(),
'product-prices' => $this->contentForProductPrices(),
'courier-information' => $this->contentForCourierInformation(),
'board-deductions' => $this->contentForBoardDeductions(),
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
'courier-surcharge' => $this->contentForCourierSurcharge(),
'platform-promotion-accounts' => app(PlatformPromotionAccountPageData::class)->content($tab),
default => null,
};
}
/**
* 快递附加费报价维护原型。
*
* 报价维护与后续附加费结算分开:本页只负责渠道、类型、结算方式、
* 计价标准和生效时间的维护展示,结算页将来根据这些规则读取业务镜像。
*/
protected function contentForCourierSurcharge(): array
{
$rows = collect([
[
'id' => 'SUR-001', 'courier' => '曹县德邦', 'type' => '超长费',
'mode' => '按尺寸/最长边', 'standard' => '最长边 > 160 cm:8 元/单',
'condition' => '最长边超过 160 cm', 'effective_at' => '2026-09-01',
'status' => '已启用', 'remark' => '大件渠道标准',
],
[
'id' => 'SUR-002', 'courier' => '曹县德邦', 'type' => '拦截费',
'mode' => '固定单价', 'standard' => '20 元/单(小件)',
'condition' => '查件结扣单类型=拦截', 'effective_at' => '2026-09-01',
'status' => '已启用', 'remark' => '小件渠道适用',
],
[
'id' => 'SUR-003', 'courier' => '常州松宇货运', 'type' => '送货费',
'mode' => '按体积', 'standard' => '体积 ≤ 10 m³:50 元/单',
'condition' => '计费体积不超过 10 m³', 'effective_at' => '2026-09-01',
'status' => '已启用', 'remark' => '送货上门费用',
],
[
'id' => 'SUR-004', 'courier' => '霸州百世快运-雅柏', 'type' => '退回费',
'mode' => '按重量', 'standard' => '2 元/kg',
'condition' => '退回件,按结算重量计费', 'effective_at' => '2026-09-01',
'status' => '已启用', 'remark' => '退回费通用标准',
],
[
'id' => 'SUR-005', 'courier' => '常州德邦', 'type' => '上楼费',
'mode' => '聚工单客服核实', 'standard' => '读取聚工单附加费明细',
'condition' => '存在客服核实的上楼工单', 'effective_at' => '2026-09-01',
'status' => '已启用', 'remark' => '不在本页直接填写金额',
],
[
'id' => 'SUR-006', 'courier' => '方建顺心捷达', 'type' => '拦截费',
'mode' => '发出运费倍数', 'standard' => '发出运费 × 2',
'condition' => '拦截成功,关联发出运费', 'effective_at' => '2026-09-01',
'status' => '已启用', 'remark' => '双向运费',
],
]);
$clean = static fn (mixed $value, int $length = 60): string => mb_substr(trim((string) $value), 0, $length);
$filters = [
'keyword' => $clean(request('keyword', ''), 40),
'courier' => $clean(request('courier', ''), 40),
'type' => $clean(request('type', ''), 40),
'mode' => $clean(request('mode', ''), 40),
'status' => $clean(request('status', ''), 20),
];
$filtered = $rows->filter(static function (array $row) use ($filters): bool {
foreach (['courier', 'type', 'mode', 'status'] as $key) {
if ($filters[$key] !== '' && $row[$key] !== $filters[$key]) {
return false;
}
}
if ($filters['keyword'] !== ''
&& ! str_contains($row['courier'], $filters['keyword'])
&& ! str_contains($row['type'], $filters['keyword'])
&& ! str_contains($row['standard'], $filters['keyword'])
&& ! str_contains($row['condition'], $filters['keyword'])) {
return false;
}
return true;
})->values();
return [
'view' => 'business.pages.courier-payables.courier-surcharge',
'courierSurchargeRows' => $filtered,
'courierSurchargeFilters' => $filters,
'courierSurchargeSummary' => [
'total' => $filtered->count(),
'active' => $filtered->where('status', '已启用')->count(),
'inactive' => $filtered->where('status', '已停用')->count(),
'couriers' => $filtered->pluck('courier')->unique()->count(),
'types' => $filtered->pluck('type')->unique()->count(),
],
'courierSurchargeOptions' => [
'couriers' => $rows->pluck('courier')->unique()->sort()->values(),
// 文档定义的十类附加费全部进入筛选器,即使当前演示样例暂未覆盖每一类。
'types' => collect([
'退回费', '拦截费', '改地址', '异形件', '超长费',
'上楼费', '送货费', '超区费', '安装费', '保费', '发出运费附加',
]),
'modes' => collect(['固定单价', '按重量', '按体积', '按尺寸/最长边', '聚工单客服核实', '取发出运费', '发出运费倍数', '按账单']),
'statuses' => ['已启用', '已停用'],
],
];
}
protected function contentForDingPayableWorkflows(): array
{
$normalizeDate = static function (mixed $value): string {
$date = trim((string) $value);
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $matches) !== 1
|| ! checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1])) {
return '';
}
return $date;
};
$status = strtoupper(mb_substr(trim((string) request('status', '')), 0, 30));
$filters = [
'process_instance_id' => mb_substr(trim((string) request('process_instance_id', '')), 0, 100),
'created_from' => $normalizeDate(request('created_from', '')),
'finished_to' => $normalizeDate(request('finished_to', '')),
'status' => preg_match('/^[A-Z_]+$/', $status) === 1 ? $status : '',
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
try {
$result = $this->dingPayableWorkflows->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
);
return [
'view' => 'business.pages.courier-payables.payable-cloud-warehouse-vehicle-fee',
'dingPayableWorkflowRows' => $rows,
'dingPayableWorkflowFilters' => $filters,
'dingPayableWorkflowStatus' => $result['status'] ?? 'unknown',
'dingPayableWorkflowSyncedAt' => $result['last_synced_at'] ?? null,
'dingPayableWorkflowError' => null,
];
} catch (Throwable $exception) {
report($exception);
return [
'view' => 'business.pages.courier-payables.payable-cloud-warehouse-vehicle-fee',
'dingPayableWorkflowRows' => new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(), 'query' => request()->except('page'),
]),
'dingPayableWorkflowFilters' => $filters,
'dingPayableWorkflowStatus' => 'unavailable',
'dingPayableWorkflowSyncedAt' => null,
'dingPayableWorkflowError' => '钉钉应付流程镜像尚未完成同步,请稍后重试。',
];
}
}
protected function contentForNonJingxiRefundData(): array
{
$request = request();
$defaultTo = now()->toDateString();
$defaultFrom = now()->subDays(6)->toDateString();
$filters = $this->nonJingxiRefundFilters($request, $defaultFrom, $defaultTo);
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
$syncStatus = ['code' => 'unavailable', 'last_succeeded_at' => null];
$error = null;
$pending = false;
$indexPending = false;
try {
if (! $this->nonJingxiRefunds->available()) {
$error = '共享业务镜像尚未建立,非京喜退款数据暂时无法读取。';
} else {
$states = $this->nonJingxiRefunds->states();
$syncStatus = $this->nonJingxiRefunds->syncStatus($states);
if (! $this->nonJingxiRefunds->ready()) {
$pending = true;
} elseif (! $this->nonJingxiRefunds->queryReady()) {
$indexPending = true;
} else {
$result = $this->nonJingxiRefunds->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
}
}
} catch (Throwable $exception) {
report($exception);
$error = '非京喜退款数据暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.refunds.non-jingxi-refund-data',
'filters' => $filters,
'rows' => $rows,
'refundSyncStatus' => $syncStatus,
'refundError' => $error,
'refundPending' => $pending,
'refundIndexPending' => $indexPending,
];
}
public function exportNonJingxiRefunds(Request $request)
{
$filters = $this->nonJingxiRefundFilters($request);
$redirectParameters = array_filter([
'section' => 'refunds',
'page' => 'non-jingxi-refund-data',
...$filters,
], static fn (mixed $value): bool => $value !== '' && $value !== null);
try {
if (! $this->nonJingxiRefunds->available()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '共享业务镜像尚未建立,售后(退货退款)暂时不能导出。');
}
if (! $this->nonJingxiRefunds->ready()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '业务镜像首次同步尚未完成,售后(退货退款)暂时不能导出。');
}
if (! $this->nonJingxiRefunds->queryReady()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '页面查询索引尚未就绪,售后(退货退款)暂时不能导出。');
}
return $this->streamNonJingxiRefundCsv(
$this->nonJingxiRefunds->exportRows($filters),
$filters,
$redirectParameters,
);
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '售后(退货退款)导出暂时无法完成,请稍后重试。');
}
}
/** @return array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} */
private function nonJingxiRefundFilters(
Request $request,
?string $defaultFrom = null,
?string $defaultTo = null,
): array {
$defaultTo ??= now()->toDateString();
$defaultFrom ??= now()->subDays(6)->toDateString();
$from = $this->dateFilter($request->query('from', $defaultFrom)) ?: $defaultFrom;
$to = $this->dateFilter($request->query('to', $defaultTo)) ?: $defaultTo;
if ($from > $to) {
[$from, $to] = [$to, $from];
}
return [
'from' => $from,
'to' => $to,
'order_no' => mb_substr(trim((string) $request->query('order_no', '')), 0, 100),
'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 191),
'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 100),
];
}
private function streamNonJingxiRefundCsv(iterable $rows, array $filters, array $redirectParameters)
{
$columns = [
['label' => '序号/主键ID', 'field' => 'Id'],
['label' => '退款日期', 'field' => 'refundDate'],
['label' => '售后单ID', 'field' => 'as_id'],
['label' => '外部售后单号', 'field' => 'outer_as_id'],
['label' => '订单ID', 'field' => 'o_id'],
['label' => '', 'field' => ''],
['label' => '子订单ID', 'field' => 'so_id'],
['label' => '类型', 'field' => 'type'],
['label' => '节点', 'field' => 'node'],
['label' => '是否自营', 'field' => 'isZC'],
['label' => '店铺名称', 'field' => 'shop_name'],
['label' => '平台', 'field' => 'platform'],
['label' => '责任部门', 'field' => 'responsibleDepartment'],
['label' => '退款总金额', 'field' => 'totalRefundMoney', 'number' => true],
['label' => '原始邮编', 'field' => 'originPostcode'],
['label' => '店铺商品ID', 'field' => 'shop_i_id'],
['label' => '店铺SKU编码', 'field' => 'shop_sku_id'],
['label' => '商品ID', 'field' => 'i_id'],
['label' => 'SKU编码', 'field' => 'sku_id'],
['label' => '买家实付金额', 'field' => 'buyer_paid_amount', 'number' => true],
['label' => '数量', 'field' => 'qty', 'number' => true],
['label' => '商品单位成本', 'field' => 'unitCostGoods', 'number' => true],
['label' => '明细退款金额', 'field' => 'detailRefundMoney', 'number' => true],
['label' => '退款类型', 'field' => 'refundType'],
['label' => '退款数量', 'field' => 'refundQty', 'number' => true],
['label' => '退款商品成本', 'field' => 'refundCostGoods', 'number' => true],
['label' => '退款回款金额', 'field' => 'RefundMoneyIncome', 'number' => true],
['label' => '售后金额', 'field' => 'AfSalesMoney', 'number' => true],
['label' => '创建时间', 'field' => 'createTime'],
['label' => '状态', 'field' => 'status'],
['label' => '店铺状态', 'field' => 'shop_status'],
['label' => '创建记录/创建时间', 'field' => 'created'],
['label' => '确认日期', 'field' => 'confirm_date'],
];
$csv = null;
try {
$csv = fopen('php://temp/maxmemory:5242880', 'w+b');
if ($csv === false) {
throw new \RuntimeException('无法创建售后退款导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($rows as $row) {
fputcsv($csv, array_map(
fn (array $column): mixed => $column['field'] === ''
? ''
: (($column['number'] ?? false)
? data_get($row, $column['field'])
: $this->csvText(data_get($row, $column['field']))),
$columns,
));
}
rewind($csv);
} catch (Throwable $exception) {
if (is_resource($csv)) {
fclose($csv);
}
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '售后(退货退款)导出暂时无法完成,请稍后重试。');
}
return response()->streamDownload(function () use ($csv): void {
fpassthru($csv);
fclose($csv);
}, 'after_sales_refunds_' . $filters['from'] . '_' . $filters['to'] . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
protected function contentForMarketingAccountBalance(?string $tab): array
{
$content = [
'view' => 'banking.marketing_account_balances.partials.embedded-table',
'platform' => $tab,
'balances' => collect(),
'error' => null,
'pending' => false,
];
if (! in_array($tab, ['taobao-alipay', 'pinduoduo'], true)) {
return $content;
}
$request = request();
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$month = trim((string) $request->query('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : '';
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'month' => $month !== '' ? $month : null,
];
$isTaobao = $tab === 'taobao-alipay';
$repository = $isTaobao
? app(TmallTaobaoSpreadBillRepository::class)
: app(PinduoduoSpreadBillRepository::class);
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
$cache = $this->upstreamCaches->forSource('jiansun');
$usingMirror = $repository->usesBusinessMirror();
try {
$resolver = fn () => $repository->paginateMonthlyBalances(
$filters['store_name'],
$filters['month'],
$page,
$pageSize,
);
$result = $usingMirror
? $resolver()
: $cache->remember(
$scope,
['view' => 'monthly-balances', 'filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$resolver,
);
} catch (UpstreamSnapshotPendingException $exception) {
$content['pending'] = true;
$result = ['rows' => [], 'total' => 0];
} catch (ServiceUnavailableHttpException $exception) {
$content['error'] = $exception->getMessage();
$result = ['rows' => [], 'total' => 0];
} catch (Throwable $exception) {
report($exception);
$content['error'] = ($isTaobao ? '淘系' : '拼多多') . '账户余额数据暂时无法读取,请稍后重试。';
$result = ['rows' => [], 'total' => 0];
}
try {
$resolver = fn () => $repository->storeOptions();
$storeOptions = $usingMirror
? $resolver()
: $cache->remember(
$isTaobao ? 'taobao-alipay-bills.options' : 'pinduoduo-bills.options',
[],
$resolver,
);
} catch (Throwable $exception) {
report($exception);
$storeOptions = [];
}
$content['balances'] = new LengthAwarePaginator(
Collection::make($result['rows'] ?? []),
(int) ($result['total'] ?? 0),
$pageSize,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
$content['filters'] = ['store_name' => $storeName, 'month' => $month];
$content['storeOptions'] = $storeOptions;
return $content;
}
public function export(Request $request, string $section, string $page, string $tab)
{
abort_unless($section === 'billing-transactions', 404);
$sectionConfig = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', $section);
abort_if(empty($sectionConfig), 403, '当前账号没有该页面权限。');
$pageConfig = collect($sectionConfig['pages'] ?? [])->firstWhere('slug', $page);
abort_if(empty($pageConfig), 403, '当前账号没有该页面权限。');
if ($page === 'platform-promotion-accounts') {
return $this->exportPlatformPromotionAccounts($request);
}
if ($page === 'marketing-account-bills') {
return $this->exportMarketingAccountBills($request, $section, $page, $tab);
}
abort_unless($page === 'marketing-account-balance', 404);
abort_unless(in_array($tab, ['taobao-alipay', 'pinduoduo'], true), 404);
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$month = trim((string) $request->query('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : '';
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'month' => $month !== '' ? $month : null,
];
$isTaobao = $tab === 'taobao-alipay';
$repository = $isTaobao
? app(TmallTaobaoSpreadBillRepository::class)
: app(PinduoduoSpreadBillRepository::class);
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
$redirectParameters = array_filter([
'section' => $section,
'page' => $page,
'tab' => $tab,
'store_name' => $storeName,
'month' => $month,
], static fn (mixed $value): bool => $value !== '');
try {
$resolver = fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000);
$result = $repository->usesBusinessMirror()
? $resolver()
: $this->upstreamCaches->forSource('jiansun')->remember(
$scope,
['view' => 'monthly-balances', 'filters' => $filters, 'page' => 1, 'limit' => 5000, 'export' => true],
$resolver,
);
} catch (ServiceUnavailableHttpException) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '首份共享快照正在准备,系统已登记刷新请求;请稍后刷新页面后再导出。');
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '余额数据导出暂时无法完成,请稍后重试。');
}
$rows = $result['rows'] ?? [];
return response()->streamDownload(function () use ($rows): void {
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'日期', '店铺名称', '期初余额', '本期收入', '本期支出', '期末余额',
'系统期末余额', '余额差异', '差异原因', '核对状态', '备注',
]);
foreach ($rows as $row) {
fputcsv($csv, [
$row['date'], $row['store_name'], $row['opening_balance'], $row['income_total'],
$row['expense_total'], $row['closing_balance'], $row['system_closing_balance'],
$row['balance_difference'], $row['difference_reason'], $row['verification_status'], $row['notes'],
]);
}
fclose($csv);
}, ($isTaobao ? 'taobao' : 'pinduoduo') . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
private function exportPlatformPromotionAccounts(Request $request)
{
$month = trim((string) $request->query('month', ''));
$month = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : null;
$filters = [
'month' => $month,
'shop_name' => mb_substr(trim((string) $request->query('shop_name', '')), 0, 100),
'account' => mb_substr(trim((string) $request->query('account', '')), 0, 100),
];
try {
$repository = app(\Modules\EcommerceFinance\Repositories\PlatformPromotionAccountRepository::class);
$filters['month'] = $month ?: ($repository->latestMonth() ?: now()->format('Y-m'));
$data = $repository->dashboard($filters, 5000);
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', [
'section' => 'billing-transactions',
'page' => 'platform-promotion-accounts',
])->with('error', '推广账户数据导出暂时无法完成,请稍后重试。');
}
return response()->streamDownload(function () use ($data): void {
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, ['平台', '店铺/账户', '日期', '类型', '金额(元)', '余额(元)', '备注']);
$emit = static function (string $platform, array $rows, array $map) use ($csv): void {
foreach ($rows as $row) {
fputcsv($csv, [
$platform,
$map['name']($row),
$map['date']($row),
$map['type']($row),
$map['amount']($row),
$map['balance']($row),
$map['remark']($row),
]);
}
};
$emit('京东京准通', $data['jd'] ?? [], ['name' => fn ($row) => $row['shop_name'] . ' / ' . $row['account'], 'date' => fn ($row) => $row['show_date'] ?? $row['settle_datetime'], 'type' => fn ($row) => $row['trade_type_name'] ?? $row['in_out_type_name'], 'amount' => fn ($row) => $row['amount'] ?? '', 'balance' => fn ($row) => $row['balance_amount'] ?? '', 'remark' => fn ($row) => $row['remark'] ?? '']);
$emit('抖音千川', $data['dy'] ?? [], ['name' => fn ($row) => $row['shopName'] . ' / ' . $row['account'] . ' / ' . $row['qianchuanAavid'], 'date' => fn ($row) => $row['statDate'] ?? '', 'type' => fn () => '花费', 'amount' => fn ($row) => $row['cost'] ?? '', 'balance' => fn ($row) => $row['balance'] ?? '', 'remark' => fn ($row) => $row['remark'] ?? '']);
$emit('得物推广', $data['dw'] ?? [], ['name' => fn ($row) => $row['shop_name'] . ' / ' . $row['account'], 'date' => fn ($row) => $row['trade_date'] ?? $row['date_time'], 'type' => fn ($row) => $row['flow_type'] ?? '', 'amount' => fn ($row) => $row['trade_amount_yuan'] ?? '', 'balance' => fn ($row) => $row['balance_yuan'] ?? '', 'remark' => fn ($row) => $row['remark'] ?? '']);
$emit('VIP营销', $data['vipMarketing'] ?? [], ['name' => fn ($row) => $row['accountName'] . ' / ' . $row['advertiserId'], 'date' => fn ($row) => $row['dataMonth'], 'type' => fn () => '月度花费', 'amount' => fn ($row) => $row['totalCostYuan'] ?? '', 'balance' => fn () => '', 'remark' => fn ($row) => '曝光 ' . $row['totalImpressionCount']]);
fclose($csv);
}, '平台推广账户流水_' . $filters['month'] . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
private function exportMarketingAccountBills(
Request $request,
string $section,
string $page,
string $tab,
) {
abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], true), 404);
return match ($tab) {
'taobao-alipay' => $this->exportTaobaoAccountBills($request, $section, $page, $tab),
'taobao-wechat' => $this->exportAlibabaAccountBills($request, $section, $page, $tab),
'jd-wallet' => $this->exportJdAccountBills($request, $section, $page, $tab),
default => $this->exportPinduoduoAccountBills($request, $section, $page, $tab),
};
}
private function exportTaobaoAccountBills(Request $request, string $section, string $page, string $tab)
{
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = in_array($request->query('flow_type'), ['income', 'expense'], true)
? (string) $request->query('flow_type')
: '';
$redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
]);
$repository = app(TmallTaobaoSpreadBillRepository::class);
if (! $repository->usesBusinessMirror()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '淘系账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
return $this->streamMarketingAccountBillCsv(
$repository->exportRows(
$storeName !== '' ? $storeName : null,
$fromDate !== '' ? $fromDate : null,
$toDate !== '' ? $toDate : null,
$flowType !== '' ? $flowType : null,
),
['店铺名称', '记账日期', '交易日期', '金额(元)', '余额(元)', '备注', 'finType', '收支类型', '交易类型'],
static fn (array $row): array => [
$row['store_name'] ?? '', $row['bookkeeping_time'] ?? '', $row['transaction_date'] ?? '',
$row['amount'] ?? 0, $row['balance'] ?? 0, $row['comment'] ?? '', $row['fin_type'] ?? '',
$row['income_expense_type'] ?? '', $row['transaction_type'] ?? '',
],
'taobao_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv',
$redirectParameters,
'淘系账单导出暂时无法完成,请稍后重试。',
);
}
private function exportAlibabaAccountBills(Request $request, string $section, string $page, string $tab)
{
$shopName = mb_substr(trim((string) $request->query('shop_name', '')), 0, 191);
$productType = mb_substr(trim((string) $request->query('product_type', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [
'shop_name' => $shopName,
'product_type' => $productType,
'from' => $fromDate,
'to' => $toDate,
]);
$repository = app(AlibabaBillRepository::class);
if (! $repository->usesBusinessMirror()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '阿里巴巴账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
return $this->streamMarketingAccountBillCsv(
$repository->exportRows(
$shopName !== '' ? $shopName : null,
$productType !== '' ? $productType : null,
$fromDate !== '' ? $fromDate : null,
$toDate !== '' ? $toDate : null,
),
['店铺名称', '交易时间', '产品类型', '入账金额(元)', '出帐金额(元)'],
static fn (array $row): array => [
$row['shop_name'] ?? '', $row['trade_day'] ?? '', $row['product_type'] ?? '',
$row['income_amount'] ?? 0, $row['expense_amount'] ?? 0,
],
'alibaba_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv',
$redirectParameters,
'阿里巴巴账单导出暂时无法完成,请稍后重试。',
);
}
private function exportJdAccountBills(Request $request, string $section, string $page, string $tab)
{
$ledger = $request->query('jd_ledger') === 'consumption' ? 'consumption' : 'recharge';
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [
'jd_ledger' => $ledger,
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
]);
$repository = app(JdWalletBillRepository::class);
if (! $repository->usesBusinessMirror($ledger)) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '京东账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
$headers = $ledger === 'consumption'
? ['店铺名称', '金额(元)', '余额(元)', '交易类型', '投放日期', '结算日期', '流水号']
: ['店铺名称', '金额(元)', '余额(元)', '交易类型', '商户订单号', '充值时间', '到账时间', '流水号', '状态', '备注'];
$rowValues = $ledger === 'consumption'
? static fn (array $row): array => [
$row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0,
$row['transaction_type'] ?? '', $row['investment_date'] ?? '',
$row['settlement_date'] ?? '', $row['serial_number'] ?? '',
]
: static fn (array $row): array => [
$row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0,
$row['transaction_type'] ?? '', $row['order_number'] ?? '', $row['recharge_time'] ?? '',
$row['arrival_time'] ?? '', $row['serial_number'] ?? '', $row['status'] ?? '', $row['notes'] ?? '',
];
return $this->streamMarketingAccountBillCsv(
$repository->exportRows(
$ledger,
$storeName !== '' ? $storeName : null,
$fromDate !== '' ? $fromDate : null,
$toDate !== '' ? $toDate : null,
),
$headers,
$rowValues,
'jd_' . $ledger . '_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv',
$redirectParameters,
'京东账单导出暂时无法完成,请稍后重试。',
);
}
private function exportPinduoduoAccountBills(Request $request, string $section, string $page, string $tab)
{
$storeName = mb_substr(trim((string) $request->query('store_name', '')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = in_array($request->query('flow_type'), ['income', 'expense'], true)
? (string) $request->query('flow_type')
: '';
$redirectParameters = $this->marketingBillRedirectParameters($section, $page, $tab, [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
]);
$repository = app(PinduoduoSpreadBillRepository::class);
if (! $repository->usesBusinessMirror()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '拼多多账单共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。');
}
return $this->streamMarketingAccountBillCsv(
$repository->exportRows(
$storeName !== '' ? $storeName : null,
$fromDate !== '' ? $fromDate : null,
$toDate !== '' ? $toDate : null,
$flowType !== '' ? $flowType : null,
),
['店铺名称', '金额(元)', '余额(元)', '摘要', '收支类型', '资金类型', '费用时间'],
static fn (array $row): array => [
$row['store_name'] ?? '', $row['amount'] ?? 0, $row['balance'] ?? 0,
$row['summary'] ?? '', $row['income_expense_type'] ?? '',
$row['fund_type'] ?? '', $row['cost_time'] ?? '',
],
'pinduoduo_account_bills_' . $this->marketingBillRange($fromDate, $toDate) . '.csv',
$redirectParameters,
'拼多多账单导出暂时无法完成,请稍后重试。',
);
}
private function streamMarketingAccountBillCsv(
iterable $rows,
array $headers,
callable $rowValues,
string $filename,
array $redirectParameters,
string $errorMessage,
) {
$csv = null;
try {
$csv = fopen('php://temp/maxmemory:5242880', 'w+b');
if ($csv === false) {
throw new \RuntimeException('无法创建账单导出临时文件。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, $headers);
foreach ($rows as $row) {
fputcsv($csv, $rowValues($row));
}
rewind($csv);
} catch (Throwable $exception) {
if (is_resource($csv)) {
fclose($csv);
}
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', $errorMessage);
}
return response()->streamDownload(function () use ($csv): void {
fpassthru($csv);
fclose($csv);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
private function marketingBillRedirectParameters(string $section, string $page, string $tab, array $filters): array
{
return array_filter(array_merge([
'section' => $section,
'page' => $page,
'tab' => $tab,
], $filters), static fn (mixed $value): bool => $value !== '' && $value !== null);
}
private function marketingBillRange(string $fromDate, string $toDate): string
{
return ($fromDate ?: 'all') . '_' . ($toDate ?: 'all');
}
protected function contentForBoardDeductions(): array
{
$filters = [
'stock_in_date' => $this->dateFilter(request('stock_in_date')),
'stock_out_date' => $this->dateFilter(request('stock_out_date')),
'furniture_factory' => mb_substr(trim((string) request('furniture_factory', '')), 0, 191),
'lamination_factory' => mb_substr(trim((string) request('lamination_factory', '')), 0, 191),
'deduction_type' => mb_substr(trim((string) request('deduction_type', '')), 0, 191),
'material_name' => mb_substr(trim((string) request('material_name', '')), 0, 191),
'claim_number' => mb_substr(trim((string) request('claim_number', '')), 0, 191),
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$cache = $this->upstreamCaches->forSource('jiansun');
try {
$result = $cache->remember(
'supplier-payables.board-deductions.list',
['filters' => $filters, 'page' => $page, 'limit' => $limit],
fn () => $this->supplierPayableReports->boardDeductions($filters, $page, $limit),
);
return [
'boardDeductions' => $this->supplierPayablePaginator((array) ($result['rows'] ?? []), $limit, $page),
'boardDeductionFilters' => $filters,
'boardDeductionStatus' => $cache->responseHeaders(),
'boardDeductionError' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page),
'boardDeductionFilters' => $filters,
'boardDeductionStatus' => [],
'boardDeductionError' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page),
'boardDeductionFilters' => $filters,
'boardDeductionStatus' => [],
'boardDeductionError' => '板材扣款数据暂时无法读取,请稍后重试。',
];
}
}
protected function contentForSupplierPayablesReport(): array
{
$month = trim((string) request('month', ''));
$filters = [
'month' => preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $month : '',
'supplier' => mb_substr(trim((string) request('supplier', '')), 0, 191),
'checker' => mb_substr(trim((string) request('checker', '')), 0, 191),
'settlement_method' => mb_substr(trim((string) request('settlement_method', '')), 0, 191),
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$cache = $this->upstreamCaches->forSource('jiansun');
try {
$result = $cache->remember(
'supplier-payables.report.list',
['filters' => $filters, 'page' => $page, 'limit' => $limit],
fn () => $this->supplierPayableReports->supplierPayables($filters, $page, $limit),
);
return [
'supplierPayables' => $this->supplierPayablePaginator((array) ($result['rows'] ?? []), $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableStatus' => $cache->responseHeaders(),
'supplierPayableError' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableStatus' => [],
'supplierPayableError' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableStatus' => [],
'supplierPayableError' => '供应商应付数据暂时无法读取,请稍后重试。',
];
}
}
private function supplierPayablePaginator(array $rows, int $limit, int $page): Paginator
{
return new Paginator(
Collection::make($rows)->map(static fn (array $row): object => (object) $row),
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
}
protected function contentForNormalProducts(): array
{
$filters = $this->normalProductFilters(request());
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 50);
try {
if (! $this->productCatalog->available()) {
return $this->emptyProductCatalogContent(
$filters,
$limit,
$page,
'共享业务镜像尚未建立,普通商品资料暂时无法读取。',
null,
['code' => 'unavailable', 'lag_minutes' => null],
);
}
$state = $this->productCatalog->state();
$syncStatus = $this->productCatalog->syncStatus($state);
if (! $this->productCatalog->ready()) {
return $this->emptyProductCatalogContent(
$filters,
$limit,
$page,
'普通商品资料正在执行首次全量同步,完成前不会把未同步数据误判为无数据。',
$state,
$syncStatus,
);
}
$result = $this->productCatalog->paginate($filters, $page, $limit);
$paginator = new LengthAwarePaginator(
$result['data'],
$result['total'],
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
return [
'productCatalogRows' => $paginator,
'productCatalogFilters' => $filters,
'productCatalogError' => null,
'productCatalogState' => $state,
'productCatalogSyncStatus' => $syncStatus,
'productCatalogPurchasePriceSyncStatus' => $result['purchase_price_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
];
} catch (Throwable $exception) {
report($exception);
return $this->emptyProductCatalogContent(
$filters,
$limit,
$page,
'共享业务数据暂时无法读取,请稍后重试。',
null,
['code' => 'unavailable', 'lag_minutes' => null],
);
}
}
public function exportNormalProducts(Request $request): StreamedResponse
{
abort_unless($this->productCatalog->available() && $this->productCatalog->ready(), 503);
$filters = $this->normalProductFilters($request);
return response()->streamDownload(function () use ($filters): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建普通商品资料导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'ID', '款式编码', '商品编码', '商品名称', '商品简称', '规格 / 颜色',
'品牌', '类目', '供应商', '销售价', '成本价', '普通商品采购价',
'1688代发单价', '数据来源', '市场价', '商品状态', '库存同步', '业务更新时间',
]);
foreach ($this->productCatalog->exportRows($filters) as $row) {
$specification = collect([$row->properties_value, $row->color])
->filter(static fn (mixed $value): bool => $value !== null && $value !== '')
->unique()
->implode(' / ');
$enabledLabel = match ((int) $row->enabled) {
1 => '已启用',
-1 => '已删除',
default => '已停用',
};
fputcsv($csv, [
$row->autoid,
$this->csvText($row->i_id),
$this->csvText($row->sku_id),
$this->csvText($row->name),
$this->csvText($row->short_name),
$this->csvText($specification),
$this->csvText($row->brand),
$this->csvText($row->category),
$this->csvText($row->supplier_name),
$row->sale_price,
$row->cost_price,
$row->purchase_price,
$row->purchase_order_unit_price,
implode(';', $row->data_sources),
$row->market_price,
$enabledLabel,
(int) $row->stock_disabled === 0 ? '正常同步' : '停止同步',
$row->modified,
]);
}
fclose($csv);
}, '普通商品资料_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
public function exportCourierInformation(Request $request): StreamedResponse
{
abort_unless($this->courierInformation->available() && $this->courierInformation->ready(), 503);
$filters = $this->courierInformationFilters($request);
return response()->streamDownload(function () use ($filters): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建快递资料导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'ID', '快递公司代码', '快递公司名称', '外部编码', '杉橙快递代码', '运费打款方式',
'是否启用', '可达服务', '是否快运', '自动寻号', '配货单号寻号', '数量拆单', '重量0寻号',
'停运时间', '合同开始时间', '合同结束时间', '联系人', '联系电话', '自定义1', '自定义2', '自定义3',
'体积0寻号', '手工单号', '解密', '电子签章经办人', '最后修改',
]);
$bool = static fn (mixed $value): string => in_array(strtolower((string) $value), ['1', 'true', 'yes', 'y'], true) || $value === true ? '是' : '否';
$date = static fn (mixed $value): string => $value === null || $value === '' ? '' : substr((string) $value, 0, 10);
foreach ($this->courierInformation->exportRows($filters) as $row) {
fputcsv($csv, [
$row->Id,
$this->csvText($row->expcode),
$this->csvText($row->expname),
$this->csvText($row->outcode),
$this->csvText($row->scexpcode),
$this->csvText($row->paymethod),
$bool($row->status),
$bool($row->addvalidsgn),
$bool($row->kuaiyunsgn),
$bool($row->autonosgn),
$bool($row->packnosgn),
$bool($row->skucountsplitsgn),
$bool($row->weight0sgn),
$date($row->stopdate),
$date($row->tradebtime),
$date($row->tradeetime),
$this->csvText($row->linker),
$this->csvText($row->linkmobile),
$this->csvText($row->undefined1),
$this->csvText($row->undefined2),
$this->csvText($row->undefined3),
$bool($row->volume0sgn),
$bool($row->handnosgn),
$bool($row->decryptsgn),
$this->csvText($row->dzqzjbr),
$date($row->modified),
]);
}
fclose($csv);
}, '快递资料_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,mixed> */
private function normalProductFilters(Request $request): array
{
$filters = [
'i_id' => mb_substr(trim((string) $request->query('i_id', '')), 0, 50),
'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 50),
'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100),
'brand' => mb_substr(trim((string) $request->query('brand', '')), 0, 100),
'category' => mb_substr(trim((string) $request->query('category', '')), 0, 100),
'supplier_name' => mb_substr(trim((string) $request->query('supplier_name', '')), 0, 191),
'enabled' => in_array((string) $request->query('enabled', ''), ['-1', '0', '1'], true)
? (string) $request->query('enabled')
: '',
'stock_disabled' => in_array((string) $request->query('stock_disabled', ''), ['0', '1'], true)
? (string) $request->query('stock_disabled')
: '',
'sale_price_min' => $this->decimalFilter($request->query('sale_price_min')),
'sale_price_max' => $this->decimalFilter($request->query('sale_price_max')),
];
if ($filters['sale_price_min'] !== null
&& $filters['sale_price_max'] !== null
&& (float) $filters['sale_price_min'] > (float) $filters['sale_price_max']) {
[$filters['sale_price_min'], $filters['sale_price_max']] = [
$filters['sale_price_max'],
$filters['sale_price_min'],
];
}
return $filters;
}
private function csvText(mixed $value): string
{
$text = trim((string) ($value ?? ''));
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
}
private function emptyProductCatalogContent(
array $filters,
int $limit,
int $page,
string $error,
?array $state,
array $syncStatus,
): array {
return [
'productCatalogRows' => new LengthAwarePaginator(
[],
0,
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
),
'productCatalogFilters' => $filters,
'productCatalogError' => $error,
'productCatalogState' => $state,
'productCatalogSyncStatus' => $syncStatus,
'productCatalogPurchasePriceSyncStatus' => [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
];
}
protected function contentForCourierInformation(): array
{
$filters = $this->courierInformationFilters(request());
$tab = (string) request()->query('courier_tab', 'express');
$tab = in_array($tab, ['express', 'networks', 'accounts', 'logs'], true) ? $tab : 'express';
$page = max((int) request()->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->except('page'),
]);
$error = null;
$pending = false;
$state = null;
$syncStatus = ['code' => 'unavailable', 'lag_minutes' => null];
$relatedAvailability = $this->courierInformation->relatedAvailability();
try {
if ($tab === 'express') {
if (! $this->courierInformation->available()) {
$error = '共享业务镜像尚未建立,快递资料暂时无法读取。';
} else {
$state = $this->courierInformation->state();
$syncStatus = $this->courierInformation->syncStatus($state);
if (! $this->courierInformation->ready()) {
$pending = true;
$error = '快递资料正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginate($filters, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
}
} elseif ($tab === 'networks') {
if (! ($relatedAvailability['networks'] ?? false)) {
$error = '快递网点镜像尚未建立,请先完成 wp_express_expNet 的首次同步。';
} elseif (! ($relatedAvailability['network_ready'] ?? false)) {
$pending = true;
$error = '快递网点正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginateNetworks($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
} elseif ($tab === 'accounts') {
if (! ($relatedAvailability['accounts'] ?? false)) {
$error = '打款账户镜像尚未建立,请先完成 wp_express_account 的首次同步。';
} elseif (! ($relatedAvailability['account_ready'] ?? false)) {
$pending = true;
$error = '打款账户正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginateAccounts($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
} else {
if (! ($relatedAvailability['logs'] ?? false)) {
$error = '操作日志镜像尚未建立,请先完成 wp_express_extension 的首次同步。';
} elseif (! ($relatedAvailability['log_ready'] ?? false)) {
$pending = true;
$error = '操作日志正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierInformation->paginateLogs($filters['expcode'] ?: null, $page, $limit);
$rows = $this->courierPaginator($result['data'] ?? [], $result['total'] ?? 0, $limit, $page);
}
}
} catch (Throwable $exception) {
report($exception);
$error = '快递资料共享镜像暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.courier-payables.courier-information',
'courierTab' => $tab,
'courierFilters' => $filters,
'courierRows' => $rows,
'courierState' => $state,
'courierSyncStatus' => $syncStatus,
'courierError' => $error,
'courierPending' => $pending,
'courierRelatedAvailability' => $relatedAvailability,
];
}
/** @return array<string,string> */
private function courierInformationFilters(Request $request): array
{
$filters = [
'expcode' => mb_substr(trim((string) $request->query('expcode', '')), 0, 64),
'expname' => mb_substr(trim((string) $request->query('expname', '')), 0, 100),
'undefined1' => mb_substr(trim((string) $request->query('undefined1', '')), 0, 100),
'undefined2' => mb_substr(trim((string) $request->query('undefined2', '')), 0, 100),
'undefined3' => mb_substr(trim((string) $request->query('undefined3', '')), 0, 100),
'tradebtime_from' => $this->dateFilter($request->query('tradebtime_from')),
'tradebtime_to' => $this->dateFilter($request->query('tradebtime_to')),
'status' => in_array((string) $request->query('status', ''), ['0', '1'], true)
? (string) $request->query('status')
: '',
];
if ($filters['tradebtime_from'] !== ''
&& $filters['tradebtime_to'] !== ''
&& $filters['tradebtime_from'] > $filters['tradebtime_to']) {
[$filters['tradebtime_from'], $filters['tradebtime_to']] = [
$filters['tradebtime_to'],
$filters['tradebtime_from'],
];
}
return $filters;
}
private function courierPaginator(array $rows, int $total, int $limit, int $page): LengthAwarePaginator
{
return new LengthAwarePaginator(
Collection::make($rows),
$total,
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
);
}
protected function contentForProductPrices(): array
{
$filters = [
'barcode' => mb_substr(trim((string) request('barcode', '')), 0, 191),
'completed_from' => $this->dateFilter(request('completed_from')),
'completed_to' => $this->dateFilter(request('completed_to')),
];
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
try {
if (! $this->productPrices->ready()) {
return $this->emptyProductPriceContent(
$filters,
$limit,
$page,
'共享业务库尚未完成建库,请等待负责人完成初始化。'
);
}
if (! $this->productPrices->initialized()) {
return $this->emptyProductPriceContent(
$filters,
$limit,
$page,
'商品价格正在执行首次全量同步,完成前不会把未同步数据误判为无数据。'
);
}
$paginator = $this->productPrices->paginate($filters, $page, $limit);
$paginator->appends(request()->except('page'));
return [
'productPrices' => $paginator,
'productPriceFilters' => $filters,
'productPriceError' => null,
];
} catch (Throwable $exception) {
report($exception);
return $this->emptyProductPriceContent(
$filters,
$limit,
$page,
'共享业务数据暂时无法读取,请稍后重试。'
);
}
}
private function emptyProductPriceContent(array $filters, int $limit, int $page, string $error): array
{
return [
'productPrices' => new LengthAwarePaginator(
[],
0,
$limit,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
),
'productPriceFilters' => $filters,
'productPriceError' => $error,
];
}
/**
* 封边条页面只读取上游快照;未启用或无快照时将错误交给页面显示,
* 避免工作台因上游暂不可用直接返回 500。
*/
protected function edgeBandingContent(string $page): ?array
{
$scope = match ($page) {
'edge-banding-prices' => 'edge-banding.prices',
'details' => 'edge-banding.details',
'edge-banding-payables' => 'edge-banding.payables',
default => null,
};
if ($scope === null) {
return null;
}
$filters = match ($page) {
'edge-banding-prices' => $this->edgeBandingFilters([
'raw_name', 'raw_code', 'production_code', 'supplier',
]),
'details' => $this->edgeBandingFilters([
'date', 'production_code', 'raw_name', 'return_flag',
'outbound_supplier', 'arrival_supplier', 'outbound_no',
]),
'edge-banding-payables' => $this->edgeBandingFilters(['period', 'supplier']),
default => [],
};
if ($page === 'edge-banding-payables' && ! isset($filters['period'])) {
$filters['period'] = '2026-06';
}
$pageNumber = max((int) request('data_page', 1), 1);
$limit = min(max((int) request('data_limit', 25), 1), 100);
$cache = $this->upstreamCaches->forSource(self::EDGE_BANDING_SOURCE);
try {
$result = $cache->remember(
$scope,
['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit],
fn () => match ($scope) {
'edge-banding.prices' => $this->edgeBanding->prices($filters, $pageNumber, $limit),
'edge-banding.details' => $this->edgeBanding->details($filters, $pageNumber, $limit),
'edge-banding.payables' => $this->edgeBanding->payables($filters, $pageNumber, $limit),
},
);
return [
'filters' => $filters,
'result' => $result,
'page' => $pageNumber,
'limit' => $limit,
'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1),
'status' => $cache->responseHeaders(),
'error' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0],
'page' => $pageNumber,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0],
'page' => $pageNumber,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => '封边条数据暂时无法读取,请稍后重试。',
];
}
}
private function edgeBandingFilters(array $fields): array
{
$filters = [];
foreach ($fields as $field) {
$value = trim((string) request($field, ''));
if ($value !== '') {
$filters[$field] = $value;
}
}
return $filters;
}
/**
* 素板价格与对账页面使用 JianSun 只读查询,并保留共享快照链路。
*/
protected function rawBoardContent(string $page): ?array
{
$scope = match ($page) {
'board-prices' => 'raw-board.prices',
'board-reconciliation' => 'raw-board.reconciliations',
default => null,
};
if ($scope === null) {
return null;
}
$filters = $scope === 'raw-board.prices'
? $this->rawBoardFilters(['raw_name', 'raw_code', 'production_code', 'supplier'])
: $this->rawBoardReconciliationFilters();
$pageNumber = max((int) request('data_page', 1), 1);
$limit = min(max((int) request('data_limit', 25), 1), 100);
try {
$cache = $this->upstreamCaches->forSource(self::RAW_BOARD_SOURCE);
$result = $cache->remember(
$scope,
['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit],
fn () => $scope === 'raw-board.prices'
? $this->rawBoardPayables->prices($filters, $pageNumber, $limit)
: $this->rawBoardPayables->reconciliations($filters, $pageNumber, $limit),
);
return [
'filters' => $filters,
'result' => $result,
'page' => $pageNumber,
'limit' => $limit,
'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1),
'status' => $cache->responseHeaders(),
'error' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return $this->emptyRawBoardContent($filters, $pageNumber, $limit, $exception->getMessage());
} catch (Throwable $exception) {
report($exception);
return $this->emptyRawBoardContent(
$filters,
$pageNumber,
$limit,
'素板应付数据暂时无法读取,请稍后重试。',
);
}
}
private function rawBoardFilters(array $fields): array
{
$filters = [];
foreach ($fields as $field) {
$value = mb_substr(trim((string) request($field, '')), 0, 100);
if ($value !== '') {
$filters[$field] = $value;
}
}
return $filters;
}
private function rawBoardReconciliationFilters(): array
{
$filters = $this->rawBoardFilters([
'production_code', 'raw_name', 'outbound_supplier',
'arrival_supplier', 'outbound_no',
]);
foreach (['date_from', 'date_to'] as $field) {
$value = $this->dateFilter(request($field));
if ($value !== '') {
$filters[$field] = $value;
}
}
$returnFlag = trim((string) request('return_flag', ''));
if (in_array($returnFlag, ['normal', 'return'], true)) {
$filters['return_flag'] = $returnFlag;
}
return $filters;
}
private function emptyRawBoardContent(array $filters, int $page, int $limit, string $error): array
{
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0, 'totals' => []],
'page' => $page,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => $error,
];
}
/**
* 板材库存页面使用 JianSun 最新素板快照,并保留生产共享快照链路。
*/
protected function inventoryContent(string $page): ?array
{
if ($page !== 'board-inventory-details') {
return null;
}
$filters = $this->inventoryFilters();
$pageNumber = max((int) request('data_page', 1), 1);
$limit = min(max((int) request('data_limit', 25), 1), 100);
try {
$cache = $this->upstreamCaches->forSource(self::INVENTORY_SOURCE);
$result = $cache->remember(
'inventory.board-details',
['filters' => $filters, 'page' => $pageNumber, 'limit' => $limit],
fn () => $this->inventory->boardInventoryDetails($filters, $pageNumber, $limit),
);
return [
'filters' => $filters,
'result' => $result,
'page' => $pageNumber,
'limit' => $limit,
'last_page' => max((int) ceil(max((int) ($result['total'] ?? 0), 0) / $limit), 1),
'status' => $cache->responseHeaders(),
'error' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return $this->emptyInventoryContent($filters, $pageNumber, $limit, $exception->getMessage());
} catch (Throwable $exception) {
report($exception);
return $this->emptyInventoryContent(
$filters,
$pageNumber,
$limit,
'板材库存数据暂时无法读取,请稍后重试。',
);
}
}
private function inventoryFilters(): array
{
$filters = [];
foreach (['manufacturer', 'specification', 'matching_value'] as $field) {
$value = mb_substr(trim((string) request($field, '')), 0, 100);
if ($value !== '') {
$filters[$field] = $value;
}
}
foreach (['date_from', 'date_to'] as $field) {
$value = $this->dateFilter(request($field));
if ($value !== '') {
$filters[$field] = $value;
}
}
return $filters;
}
private function emptyInventoryContent(array $filters, int $page, int $limit, string $error): array
{
return [
'filters' => $filters,
'result' => ['data' => [], 'total' => 0, 'totals' => []],
'page' => $page,
'limit' => $limit,
'last_page' => 1,
'status' => [],
'error' => $error,
];
}
/**
* 种菜应收对账 - 加载对账数据(21字段)
*/
protected function contentForZhongcaiReconciliation(): array
{
return [
'view' => 'store_receivables.zhongcai_reconciliations.partials.embedded-table',
'records' => StoreZhongcaiReconciliation::orderBy('bill_date', 'desc')
->orderBy('id', 'desc')
->paginate(setting('default.list_limit', 25)),
];
}
protected function contentForMarketingAccountBills(?string $tab): ?array
{
if (! in_array($tab, self::MARKETING_PLATFORMS, true)) {
return null;
}
// Alibaba 页面沿用历史 taobao-wechat 路由,数据来自 JianSun.dbo.alibaba_bill。
if ($tab === 'taobao-wechat') {
$request = request();
$shopName = mb_substr(trim((string) $request->query('shop_name')), 0, 191);
$productType = mb_substr(trim((string) $request->query('product_type')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$filters = [
'shop_name' => $shopName !== '' ? $shopName : null,
'product_type' => $productType !== '' ? $productType : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
];
$repository = app(AlibabaBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$usingMirror = $repository->usesBusinessMirror();
try {
$listResolver = fn () => $repository->paginate(
$filters['shop_name'], $filters['product_type'],
$filters['from'], $filters['to'], $page, $pageSize,
);
$summaryResolver = fn () => $repository->summary(
$filters['shop_name'], $filters['product_type'], $filters['from'], $filters['to'],
);
$optionsResolver = fn () => $repository->filterOptions();
$result = $usingMirror
? $listResolver()
: $cache->remember(
'marketing-account-bills.alibaba.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$listResolver,
);
$summary = $usingMirror
? $summaryResolver()
: $cache->remember(
'marketing-account-bills.alibaba.summary',
['filters' => $filters],
$summaryResolver,
);
$options = $usingMirror
? $optionsResolver()
: $cache->remember(
'marketing-account-bills.alibaba.options',
[],
$optionsResolver,
);
} catch (ServiceUnavailableHttpException $exception) {
return [
'view' => 'banking.marketing_alibaba_bills.partials.embedded-table',
'bills' => new LengthAwarePaginator([], 0, $pageSize, $page, [
'path' => $request->url(), 'query' => $request->query(),
]),
'filters' => $filters,
'summary' => ['income_total' => 0, 'expense_total' => 0, 'net' => 0, 'count' => 0],
'options' => ['shops' => [], 'product_types' => []],
'error' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'view' => 'banking.marketing_alibaba_bills.partials.embedded-table',
'bills' => new LengthAwarePaginator([], 0, $pageSize, $page, [
'path' => $request->url(), 'query' => $request->query(),
]),
'filters' => $filters,
'summary' => ['income_total' => 0, 'expense_total' => 0, 'net' => 0, 'count' => 0],
'options' => ['shops' => [], 'product_types' => []],
'error' => 'Alibaba 账单数据暂时无法读取,请稍后重试。',
];
}
$bills = new LengthAwarePaginator(
Collection::make($result['rows'] ?? []),
(int) ($result['total'] ?? 0),
$pageSize,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
return [
'view' => 'banking.marketing_alibaba_bills.partials.embedded-table',
'bills' => $bills,
'filters' => $filters,
'summary' => $summary,
'options' => $options,
'error' => null,
];
}
// 淘系支付宝 - 从上游 JianSun.tmall_spread_bills 读取并按表1字段展示(9列)
if ($tab === 'taobao-alipay') {
$request = request();
$storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = trim((string) $request->query('flow_type'));
$flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : '';
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$repository = app(TmallTaobaoSpreadBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
'flow_type' => $flowType !== '' ? $flowType : null,
];
$listResolver = fn () => $repository->paginate(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type'],
$page,
$pageSize
);
$summaryResolver = fn () => $repository->summary(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type']
);
$optionsResolver = fn () => $repository->storeOptions();
$usingMirror = $repository->usesBusinessMirror();
$result = $usingMirror
? $listResolver()
: $cache->remember(
'taobao-alipay-bills.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$listResolver,
);
$summary = $usingMirror
? $summaryResolver()
: $cache->remember(
'taobao-alipay-bills.summary',
['filters' => $filters],
$summaryResolver,
);
$storeOptions = $usingMirror
? $optionsResolver()
: $cache->remember(
'taobao-alipay-bills.options',
[],
$optionsResolver,
);
$bills = new LengthAwarePaginator(
Collection::make($result['rows']),
$result['total'],
$pageSize,
$page,
[
'path' => $request->url(),
'query' => $request->query(),
]
);
return [
'view' => 'banking.marketing_taobao_alipay_bills.partials.embedded-table',
'bills' => $bills,
'filters' => [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
'store_options' => $storeOptions,
],
'summary' => $summary,
];
}
// 拼多多 - 从上游 JianSun.pdd_spread_bills 读取并按表1字段展示(7列)
// 表1列:店铺名称 | 金额 | 余额 | 摘要 | 收支类型 | 资金类型 | 费用时间
if ($tab === 'pinduoduo') {
$request = request();
$storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$flowType = trim((string) $request->query('flow_type'));
$flowType = in_array($flowType, ['income', 'expense'], true) ? $flowType : '';
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$repository = app(PinduoduoSpreadBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
'flow_type' => $flowType !== '' ? $flowType : null,
];
// 页面只读取共享查询快照;冷查询由统一限流和熔断链路访问上游。
$listResolver = fn () => $repository->paginate(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type'],
$page,
$pageSize
);
$summaryResolver = fn () => $repository->summary(
$filters['store_name'],
$filters['from'],
$filters['to'],
$filters['flow_type']
);
$optionsResolver = fn () => $repository->storeOptions();
$usingMirror = $repository->usesBusinessMirror();
$result = $usingMirror
? $listResolver()
: $cache->remember(
'pinduoduo-bills.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
$listResolver,
);
$summary = $usingMirror
? $summaryResolver()
: $cache->remember(
'pinduoduo-bills.summary',
['filters' => $filters],
$summaryResolver,
);
$storeOptions = $usingMirror
? $optionsResolver()
: $cache->remember(
'pinduoduo-bills.options',
[],
$optionsResolver,
);
// 包装成 Laravel 分页器,便于 Blade 用 $bills->links()
$bills = new LengthAwarePaginator(
Collection::make($result['rows']),
$result['total'],
$pageSize,
$page,
[
'path' => $request->url(),
'query' => $request->query(),
]
);
return [
'view' => 'banking.marketing_pinduoduo_bills.partials.embedded-table',
'bills' => $bills,
'filters' => [
'store_name' => $storeName,
'from' => $fromDate,
'to' => $toDate,
'flow_type' => $flowType,
'store_options' => $storeOptions,
],
'summary' => $summary,
];
}
// 京东钱包 - 按附件表1分别展示充值账单和消耗账单,数据来自 JianSun 上游只读表。
if ($tab === 'jd-wallet') {
$request = request();
$ledger = $request->query('jd_ledger') === 'consumption' ? 'consumption' : 'recharge';
$storeName = mb_substr(trim((string) $request->query('store_name')), 0, 191);
$fromDate = $this->dateFilter($request->query('from'));
$toDate = $this->dateFilter($request->query('to'));
$page = max(1, (int) $request->query('page', 1));
$pageSize = min(max((int) setting('default.list_limit', 25), 1), 100);
$filters = [
'store_name' => $storeName !== '' ? $storeName : null,
'from' => $fromDate !== '' ? $fromDate : null,
'to' => $toDate !== '' ? $toDate : null,
];
$repository = app(JdWalletBillRepository::class);
$cache = $this->upstreamCaches->forSource('jiansun');
$scopePrefix = 'jd-wallet-bills.' . $ledger;
$errors = [];
$usingMirror = $repository->usesBusinessMirror($ledger);
$remember = function (string $scope, array $parameters, callable $resolver, mixed $fallback) use ($cache, $usingMirror, &$errors): mixed {
try {
return $usingMirror ? $resolver() : $cache->remember($scope, $parameters, $resolver);
} catch (ServiceUnavailableHttpException $exception) {
$errors[] = $exception->getMessage();
} catch (Throwable $exception) {
report($exception);
$errors[] = '京东账单数据暂时无法读取,请稍后重试。';
}
return $fallback;
};
// 三项分别读取,确保生产冷启动时一次请求就能登记列表、汇总和选项,
// 同时保留已经命中的共享快照,不因另一项暂缺而清空整页。
$result = $remember(
$scopePrefix . '.list',
['filters' => $filters, 'page' => $page, 'limit' => $pageSize],
fn () => $ledger === 'consumption'
? $repository->paginateConsumption($filters['store_name'], $filters['from'], $filters['to'], $page, $pageSize)
: $repository->paginateRecharge($filters['store_name'], $filters['from'], $filters['to'], $page, $pageSize),
['rows' => [], 'total' => 0],
);
$summary = $remember(
$scopePrefix . '.summary',
['filters' => $filters],
fn () => $ledger === 'consumption'
? $repository->summaryConsumption($filters['store_name'], $filters['from'], $filters['to'])
: $repository->summaryRecharge($filters['store_name'], $filters['from'], $filters['to']),
['amount_total' => 0, 'balance_total' => 0, 'count' => 0],
);
try {
$optionsResolver = fn () => $repository->shopOptions();
$shopOptions = $repository->usesBusinessMirrorForOptions()
? $optionsResolver()
: $cache->remember('jd-wallet-bills.options', [], $optionsResolver);
} catch (ServiceUnavailableHttpException $exception) {
$errors[] = $exception->getMessage();
$shopOptions = [];
} catch (Throwable $exception) {
report($exception);
$errors[] = '京东账单数据暂时无法读取,请稍后重试。';
$shopOptions = [];
}
return [
'view' => 'banking.marketing_jd_wallet_bills.partials.embedded-table',
'ledger' => $ledger,
'bills' => new LengthAwarePaginator(
Collection::make($result['rows'] ?? []),
(int) ($result['total'] ?? 0),
$pageSize,
$page,
['path' => $request->url(), 'query' => $request->query()],
),
'filters' => $filters,
'summary' => $summary,
'shopOptions' => $shopOptions,
'error' => $errors === []
? null
: implode(' ', array_values(array_unique(array_filter($errors)))),
];
}
return [
'view' => 'banking.marketing_account_bills.partials.embedded-table',
'platform' => $tab,
'accounts' => MarketingAccountBill::platform($tab)
->orderBy('bill_date', 'desc')
->orderBy('id', 'desc')
->paginate(setting('default.list_limit', 25)),
];
}
public function store(Request $request, string $section, string $page, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
$data = $request->validate($this->validationRules($descriptor));
if ($this->isStoreReceivingAccountPage($section, $page)) {
abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404);
$allowedPlatforms = TableRegistry::tabValues($section, $page, $tab);
if (in_array($tab, ['douyin', 'pinduoduo'], true)) {
$data['store_platform'] = $allowedPlatforms[0];
} else {
abort_unless(in_array($data['store_platform'] ?? null, $allowedPlatforms, true), 422, '当前页签不支持该平台类型。');
}
} elseif ($descriptor->tabFilterColumn && $tab && ($tabValue = TableRegistry::tabValue($section, $page, $tab))) {
$data[$descriptor->tabFilterColumn] = $tabValue;
}
foreach (TableRegistry::fixedFilters($section, $page) as $column => $value) {
$data[$column] = $value;
}
($descriptor->model)::create($data);
return $this->redirectBack($section, $page, $tab);
}
public function import(
Request $request,
BusinessTableImporter $importer,
string $section,
string $page,
?string $tab = null,
) {
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
]);
if ($this->isStoreReceivingAccountPage($section, $page)) {
abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404);
$result = app(StoreReceivingAccountWorkbookImporter::class)->import(
$request->file('import'),
$tab,
(int) (company_id() ?: 1),
);
} else {
$result = $importer->import($request->file('import'), $descriptor, $section, $page, $tab);
}
$message = "导入完成:新增 {$result['created']} 条,更新 {$result['updated']} 条。";
if (($result['skipped'] ?? 0) > 0) {
$message .= " 跳过 {$result['skipped']} 条空 ERP 店铺名称示例行。";
}
return $this->redirectBack($section, $page, $tab)->with('success', $message);
}
public function storeReceivingAccountTemplate(string $section, string $page, string $tab)
{
abort_unless($this->isStoreReceivingAccountPage($section, $page), 404);
abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404);
return app(StoreReceivingAccountTemplate::class)->download($tab);
}
public function update(Request $request, string $section, string $page, $id, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
$row = ($descriptor->model)::findOrFail($id);
$row->update($request->validate($this->validationRules($descriptor)));
return $this->redirectBack($section, $page, $tab);
}
public function destroy(string $section, string $page, $id, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
($descriptor->model)::findOrFail($id)->delete();
return $this->redirectBack($section, $page, $tab);
}
protected function validationRules($descriptor): array
{
$rules = [];
foreach ($descriptor->fields as $field) {
$rules[$field['name']] = match ($field['type']) {
'number', 'currency' => ['nullable', 'numeric'],
'boolean' => ['nullable', 'boolean'],
'date' => ['nullable', 'date'],
'select' => ['nullable', 'string', 'max:64'],
default => ['nullable', 'string', 'max:255'],
};
}
return $rules;
}
protected function redirectToPage(array $section, array $page)
{
$parameters = [
'section' => $section['slug'],
'page' => $page['slug'],
];
if (! empty($page['tabs'])) {
$parameters['tab'] = $page['tabs'][0]['slug'];
}
return redirect()->route('business.pages.show', $parameters);
}
private function dateFilter(mixed $value): string
{
$date = trim((string) $value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return '';
}
[$year, $month, $day] = array_map('intval', explode('-', $date));
return checkdate($month, $day, $year) ? $date : '';
}
private function decimalFilter(mixed $value): ?string
{
$value = trim((string) $value);
if ($value === '' || ! is_numeric($value)) {
return null;
}
return (string) (float) $value;
}
protected function redirectBack(string $section, string $page, ?string $tab)
{
$parameters = [
'company_id' => company_id() ?: 1,
'section' => $section,
'page' => $page,
];
if ($tab) {
$parameters['tab'] = $tab;
}
return redirect()->route('business.pages.show', $parameters)->with('success', true);
}
}