qiuxiang c46a439d73 聚水潭订单标签明细分页总数改为缓存
无筛选时 COUNT(*) 在 233 万行上要 2 秒(冷缓存更久);改为缓存总数(30 分钟,导入时预热)并手工构造分页器,翻页不再重复统计。
2026-09-30 17:48:35 +08:00

4971 lines
219 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\InventoryWarehouseNameMappingWorkbookImporter;
use App\Business\InventoryWarehouseShortNames;
use App\Business\CourierPayableSummaryFilter;
use App\Business\CourierPayableSummaryStore;
use App\Business\FundForecastTemplate;
use App\Business\TableRegistry;
use App\Business\UpstreamBusinessInformation;
use App\Business\StoreReceivingAccountTemplate;
use App\Business\StoreReceivingAccountWorkbookImporter;
use App\Business\WithdrawalManagementReport;
use App\Jobs\Common\InventoryReportExport;
use App\Models\Banking\MarketingAccountBill;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Business\CourierSigningSubject;
use App\Models\Business\InstallationDetail;
use App\Models\Business\InventoryWarehouseNameMapping;
use App\Business\CourierSurchargeAuditLogRepository;
use App\Business\CourierSurchargeQuoteRepository;
use App\Business\CourierSurchargeTypeRepository;
use App\Models\StoreReceivables\StoreZhongcaiReconciliation;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Database\QueryException;
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository;
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
use Modules\EcommerceFinance\View\BillOffsetPageData;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
use Modules\EcommerceFinance\Repositories\InventoryRepository;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\JdPromotionAccountBalanceRepository;
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\DingOnlineDaifaPriceMirrorRepository;
use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\AccountMappingValidator;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry;
use Modules\EcommerceFinance\Services\PayablesFinalRegistry;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\View\PlatformPromotionAccountPageData;
use Modules\EcommerceFinance\View\OrderDataPageData;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Throwable;
class Pages extends Controller
{
private const EDGE_BANDING_SOURCE = 'jiansun';
private const INVENTORY_SOURCE = 'jiansun';
private const RAW_BOARD_SOURCE = 'jiansun';
/**
* 「终版」目录只提供空的表结构,数据由各自按表导入,不解析其他目录的既有页面内容。
*/
private const PAYABLES_FINAL_SECTIONS = ['supplier-payables-final', 'courier-payables-final'];
public function __construct(
private readonly EdgeBandingRepository $edgeBanding,
private readonly InventoryRepository $inventory,
private readonly InstallationDetailMirrorRepository $installationDetails,
private readonly NonJingxiRefundMirrorRepository $nonJingxiRefunds,
private readonly SmallPaymentWorkOrderMirrorRepository $smallPaymentWorkOrders,
private readonly ProductCatalogMirrorRepository $productCatalog,
private readonly ProductPriceMirrorRepository $productPrices,
private readonly CourierInformationMirrorRepository $courierInformation,
private readonly CourierFreightReconciliationMirrorRepository $courierFreightReconciliations,
private readonly ThirdPartyWarehouseStorageMirrorRepository $thirdPartyWarehouseStorage,
private readonly RawBoardPayablesRepository $rawBoardPayables,
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,
private readonly CourierSurchargeAuditLogRepository $courierSurchargeAudits,
private readonly WithdrawalManagementReport $withdrawalManagementReport,
private readonly ShippingOrderRepository $shippingOrders,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
/**
* 支持的营销账户平台列表
*/
protected const MARKETING_PLATFORMS = [
'taobao-alipay', 'taobao-wechat', 'pinduoduo',
'jd-wallet', 'douyin', 'dewu', 'vipshop',
'jd-promotion', 'douyin-promotion', 'dewu-promotion', 'vip-promotion',
];
/** @var list<string> 推广账户流水已合并到营销账户账单页的页签 */
protected const PROMOTION_MARKETING_TABS = [
'jd-promotion', 'douyin-promotion', 'dewu-promotion', 'vip-promotion',
];
protected const ACCOUNT_BALANCE_SECTION = 'account-balance-data';
protected const SMALL_PAYMENT_SECTION = 'financial-operations';
protected const ACCOUNT_BALANCE_PAGES = [
'bank-balance',
'store-receiving-balance',
'marketing-account-balance',
'deposit-account-balance',
];
public function home()
{
$section = collect(SidebarPermissions::navigationFor(user()))->first();
if (empty($section)) {
return view('business.access-pending');
}
$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)
{
if ($section === 'refunds' && $page === 'small-payment-work-orders') {
$url = route('business.pages.show', [
'section' => self::SMALL_PAYMENT_SECTION,
'page' => $page,
]);
$query = request()->getQueryString();
return redirect()->to($url . ($query === null ? '' : '?' . $query));
}
if ($section === 'billing-transactions' && in_array($page, self::ACCOUNT_BALANCE_PAGES, true)) {
$url = route('business.pages.show', array_filter([
'section' => self::ACCOUNT_BALANCE_SECTION,
'page' => $page,
'tab' => $tab,
], static fn (mixed $value): bool => $value !== null));
$query = request()->getQueryString();
return redirect()->to($url . ($query === null ? '' : '?' . $query));
}
$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) {
if ($section === 'courier-payables' || in_array($section, self::PAYABLES_FINAL_SECTIONS, true)) {
return view('business.pages.show', $this->viewData($sectionConfig));
}
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'],
]);
}
if ($section === 'courier-payables' && $page === 'freight-reconciliation' && request()->boolean('courier_summary')) {
try {
$summary = $this->courierFreightReconciliations->requestSummary($this->freightReconciliationFilters());
$response = response()->json(['data' => $summary], $summary === null ? 202 : 200, [
'Cache-Control' => 'private, no-store', 'X-Accel-Buffering' => 'no',
]);
// Explicit framing lets clients finish reading before the
// application termination callback computes the aggregate.
return $response->header('Content-Length', strlen($response->getContent()));
} catch (Throwable $exception) {
report($exception);
return response()->json(['message' => '完整汇总暂时无法计算,明细仍可正常查看。'], 503, ['Cache-Control' => 'private, no-store']);
}
}
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
$tabs,
null,
$this->resolveContentForSection($section, $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->resolveContentForSection($section, $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',
]);
}
public function exportBillOffset(Request $request): StreamedResponse
{
$section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'order-data');
abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'bill-offset'), 403, '当前账号没有该页面权限。');
$repository = app(BillOffsetPageData::class);
$filters = $request->query();
$result = $repository->data($filters, 1, 100);
abort_if($result['billOffsetError'] !== null, 503, $result['billOffsetError']);
$filters = $result['billOffsetFilters'];
return response()->streamDownload(function () use ($repository, $filters, $result): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建账单冲减导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
$labels = [
'internal_order_no' => '内部订单号', 'online_order_no' => '线上订单号',
'shop_name' => '店铺名称', 'shop_code' => '店铺编码', 'product_code' => '商品编码',
'style_code' => '款式编码(参考)', 'shop_product_code' => '店铺商品编码',
'shop_style_code' => '店铺款式编码', 'account_500112' => '500112冲减收入',
];
fputcsv($csv, array_values($labels));
$current = $result;
$page = 1;
do {
foreach ($current['billOffsetRows'] as $row) {
fputcsv($csv, array_map(
function (string $field) use ($row): string {
$value = data_get($row, $field);
if ($field === 'account_500112') {
return $value === null || $value === '' ? '' : number_format((float) $value, 2, '.', '');
}
return $field === 'shop_name'
? $this->csvText($value)
: $this->csvIdentifier($value);
},
array_keys($labels),
));
}
if (! $current['billOffsetRows']->hasMorePages()) break;
$current = $repository->dataForNormalizedFilters($filters, ++$page, 100);
if ($current['billOffsetError'] !== null) {
throw new \RuntimeException('账单冲减导出未完成,请重试。');
}
} while (true);
fclose($csv);
}, '账单冲减_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/**
* 商品月加权平均单价:读取已计算的月份数据,默认最新月份。
*/
protected function monthlyWeightedAveragePriceRows(): LengthAwarePaginator
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$months = DB::connection($connection)->table('product_monthly_weighted_average_states')
->where('status', 'computed')
->orderByDesc('report_month')
->pluck('report_month')
->map(static fn ($month): string => substr((string) $month, 0, 10))
->all();
$requested = substr(trim((string) request('month', '')), 0, 10);
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1) {
// 筛选栏的月份选择器提交 Y-m,这里补成当月 1 号再匹配。
$requested .= '-01';
}
// 明确指定月份时严格按该月份取数(该月没算出来就显示空表),避免筛选条件与数据不一致。
$month = $requested !== '' ? $requested : ($months[0] ?? null);
$limit = min(max((int) setting('default.list_limit', 25), 1), 50);
$page = max((int) request('page', 1), 1);
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
if ($month === null) {
return $empty;
}
$query = DB::connection($connection)->table('product_monthly_weighted_average_rows')
->where('report_month', $month);
$productCode = trim((string) request('product_code', ''));
if ($productCode !== '') {
// 与进销存总表一致:商品编码按精确查询。
$query->where('product_code', $productCode);
}
$provider = trim((string) request('warehouse_provider', ''));
if ($provider !== '') {
$query->where('warehouse_short_name', 'like', '%' . addcslashes($provider, '%_\\') . '%');
}
$category = trim((string) request('category', ''));
if ($category !== '') {
$query->where('product_name', 'like', '%' . addcslashes($category, '%_\\') . '%');
}
$total = (clone $query)->count();
if ($total === 0) {
return $empty;
}
$items = $query
->orderBy('warehouse_short_name')
->orderBy('product_code')
->forPage($page, $limit)
->get([
DB::raw('report_month as month'),
'barcode_warehouse',
'warehouse_short_name',
'supplier',
'product_code',
'product_name',
'opening_quantity',
'opening_unit_price',
'opening_original_amount',
DB::raw('adjusted_unit_price as opening_adjusted_price'),
'opening_amount',
'purchase_quantity',
'purchase_unit_price',
'purchase_amount',
'weighted_average_unit_price',
'pricing_status',
]);
return new LengthAwarePaginator($items, $total, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
}
/**
* 商品月加权平均单价导出:按当前月份与筛选条件导出 CSV,列序跟随页面显示列。
*/
public function exportMonthlyWeightedAveragePrice(Request $request): StreamedResponse
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$map = [
'month' => ['report_month', '月份'],
'barcode_warehouse' => ['barcode_warehouse', '条码+仓库'],
'warehouse_short_name' => ['warehouse_short_name', '仓库简称'],
'supplier' => ['supplier', '供应商'],
'product_code' => ['product_code', '商品编码'],
'product_name' => ['product_name', '商品名称'],
'opening_quantity' => ['opening_quantity', '原始数量'],
'opening_unit_price' => ['opening_unit_price', '原始价格'],
'opening_original_amount' => ['opening_original_amount', '原始期初金额'],
'opening_adjusted_price' => ['adjusted_unit_price', '更改价格'],
'opening_amount' => ['opening_amount', '期初金额'],
'purchase_quantity' => ['purchase_quantity', '采购数量'],
'purchase_unit_price' => ['purchase_unit_price', '采购单价'],
'purchase_amount' => ['purchase_amount', '入库金额'],
'weighted_average_unit_price' => ['weighted_average_unit_price', '加权平均单价'],
'pricing_status' => ['pricing_status', '定价状态'],
];
$months = DB::connection($connection)->table('product_monthly_weighted_average_states')
->where('status', 'computed')->orderByDesc('report_month')->pluck('report_month')
->map(static fn ($month): string => substr((string) $month, 0, 10))->all();
$requested = substr(trim((string) $request->query('month', '')), 0, 10);
if (preg_match('/^\d{4}-\d{2}$/', $requested) === 1) {
// 导出时筛选栏提交的也是 Y-m,同样补成当月 1 号。
$requested .= '-01';
}
$month = $requested !== '' ? $requested : ($months[0] ?? null);
$query = DB::connection($connection)->table('product_monthly_weighted_average_rows');
if ($month !== null) {
$query->where('report_month', $month);
}
$code = trim((string) $request->query('product_code', ''));
if ($code !== '') {
$query->where('product_code', $code);
}
$provider = trim((string) $request->query('warehouse_provider', ''));
if ($provider !== '') {
$query->where('warehouse_short_name', 'like', '%' . addcslashes($provider, '%_\\') . '%');
}
$category = trim((string) $request->query('category', ''));
if ($category !== '') {
$query->where('product_name', 'like', '%' . addcslashes($category, '%_\\') . '%');
}
$selected = array_values(array_intersect(
array_filter(explode(',', (string) $request->query('columns', ''))),
array_keys($map),
));
if ($selected === []) {
$selected = array_keys($map);
}
$size = min(max((int) setting('default.list_limit', 25), 1), 50);
if ($request->query('scope') === 'page') {
$query->forPage(max((int) $request->query('page', 1), 1), $size);
}
$filename = '商品月加权平均单价_' . ($month ?? 'all') . '.csv';
return response()->streamDownload(function () use ($query, $selected, $map): void {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_map(static fn (string $key): string => $map[$key][1], $selected));
$query->orderBy('warehouse_short_name')->orderBy('product_code')
->chunk(1000, function ($rows) use ($out, $selected, $map): void {
foreach ($rows as $row) {
$line = [];
foreach ($selected as $key) {
$value = $row->{$map[$key][0]} ?? '';
$line[] = is_string($value) ? $value : (string) $value;
}
fputcsv($out, $line);
}
});
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/**
* 进销存报表 → 入库明细数据导出(按月份/仓库/条码筛选,列顺序与页面一致)。
*/
public function exportInventoryInboundDetail(Request $request): StreamedResponse
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$labels = [
'source_no' => '来源单号', 'document_no' => '单据编号(采购单号)', 'created_serial' => '创建时间',
'supplier' => '供应商', 'item_no' => '货号', 'barcode' => '条码', 'spec_name' => '规格名',
'quantity' => '库存变动量', 'unit_price' => '单价', 'amount' => '金额',
'estimated_unit_price' => '预估单价', 'estimated_amount' => '预估金额',
'total' => '总计',
'supplier_barcode' => '供应商条码', 'remark' => '备注', 'warehouse' => '仓库',
'warehouse_short_name' => '仓库简称', 'created_at_raw' => '创建时间(实际)',
'express_name' => '快递名称', 'express_no' => '快递单号', 'not_sent' => '未发出', 'owner_name' => '负责人',
];
$query = DB::connection($connection)->table('inventory_inbound_detail_rows');
$month = substr(trim((string) $request->query('detail_month', '')), 0, 10);
if ($month !== '') {
$query->where('report_month', $month);
}
$warehouse = trim((string) $request->query('detail_warehouse', ''));
if ($warehouse !== '') {
$like = '%' . addcslashes($warehouse, '%_\\') . '%';
$query->where(function ($inner) use ($like): void {
$inner->where('warehouse', 'like', $like)->orWhere('warehouse_short_name', 'like', $like);
});
}
$barcode = trim((string) $request->query('detail_barcode', ''));
if ($barcode !== '') {
$query->where('barcode', 'like', '%' . addcslashes($barcode, '%_\\') . '%');
}
$filename = '入库明细数据_' . ($month !== '' ? $month : 'all') . '.csv';
return response()->streamDownload(function () use ($query, $labels): void {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, array_values($labels));
$query->orderBy('row_key')->chunk(1000, function ($rows) use ($out, $labels): void {
foreach ($rows as $row) {
$line = [];
foreach (array_keys($labels) as $column) {
if ($column === 'total') {
// 总计 = 金额 + 预估金额
$line[] = number_format((float) ($row->amount ?? 0) + (float) ($row->estimated_amount ?? 0), 2);
continue;
}
$value = $row->{$column} ?? '';
$line[] = is_string($value) ? $value : (string) $value;
}
fputcsv($out, $line);
}
});
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
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;
$inventoryWarehouseMappings = collect();
$agreementCourierNames = $this->agreementCourierNames($page['slug'] ?? null);
$agreementCourierAccounts = $this->agreementCourierAccounts($page['slug'] ?? null, $agreementCourierNames);
if (($section['slug'] ?? null) === 'product-inventory' && ($page['slug'] ?? null) === 'inventory-report') {
if (request()->boolean('mapping')) {
$rows = new LengthAwarePaginator([], 0, 1, 1, [
'path' => request()->url(),
'query' => request()->query(),
]);
} else {
[$rows, $upstreamError] = $this->inventoryReportRows();
}
$inventoryWarehouseMappings = $this->inventoryWarehouseMappingRows();
}
if (($section['slug'] ?? null) === 'product-inventory'
&& ($page['slug'] ?? null) === 'monthly-weighted-average-price') {
$rows = $this->monthlyWeightedAveragePriceRows();
}
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']),
'agreementQuoteSignatures' => $this->agreementQuoteSignatures($page['slug']),
'agreementSubjects' => $this->agreementSubjects($page['slug']),
'agreementCourierNames' => $agreementCourierNames,
'agreementCourierAccounts' => $agreementCourierAccounts,
'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug']),
'inventoryWarehouseMappings' => $inventoryWarehouseMappings,
];
}
if (($section['slug'] ?? null) === 'product-inventory'
&& ($page['slug'] ?? null) === 'inventory-report') {
// inventoryReportRows() 已从聚水潭共享镜像按全仓口径取数。
// 这里必须保留该结果,不能再被目录表的旧演示数据覆盖。
} elseif ($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),
'agreementQuoteSignatures' => $this->agreementQuoteSignatures($page['slug'] ?? null),
'agreementSubjects' => $this->agreementSubjects($page['slug'] ?? null),
'agreementCourierNames' => $agreementCourierNames,
'agreementCourierAccounts' => $agreementCourierAccounts,
'agreementQuoteVersions' => $this->agreementQuoteVersions($page['slug'] ?? null),
'inventoryWarehouseMappings' => $inventoryWarehouseMappings,
'payablesFinal' => $this->payablesFinalData($section, $page),
];
}
/**
* 「终版」目录的按表数据:独立存放,初始为空,由页面上的导入/导出/查询/编辑维护。
*
* @return array<string,mixed>|null
*/
protected function payablesFinalData(array $section, ?array $page): ?array
{
$sectionSlug = (string) ($section['slug'] ?? '');
if (empty($page) || ! in_array($sectionSlug, [
PayablesFinalRegistry::SECTION_SUPPLIER,
PayablesFinalRegistry::SECTION_COURIER,
], true)) {
return null;
}
$dataset = (string) ($page['slug'] ?? '');
$registry = app(PayablesFinalRegistry::class);
if (! $registry->supports($sectionSlug, $dataset)) {
return null;
}
$keyword = trim((string) request('keyword', ''));
$rows = $this->supplierPayableOverrides->listRows(
PayablesFinalActions::DATASET_PREFIX
. ($sectionSlug === PayablesFinalRegistry::SECTION_COURIER ? 'courier:' : 'supplier:')
. $dataset,
(int) (company_id() ?: 1),
);
if ($keyword !== '') {
$needle = mb_strtolower($keyword);
$rows = array_values(array_filter($rows, static function (array $row) use ($needle): bool {
foreach ($row as $key => $value) {
if ($key === '_action_key' || $value === null) {
continue;
}
if (str_contains(mb_strtolower((string) $value), $needle)) {
return true;
}
}
return false;
}));
}
$perPage = 50;
$currentPage = max((int) request('page', 1), 1);
return [
'dataset' => $dataset,
'fields' => collect($registry->fields($sectionSlug, $dataset))
->map(static fn (array $definition, string $name): array => ['name' => $name] + $definition)
->values()
->all(),
'rows' => new LengthAwarePaginator(
array_slice($rows, ($currentPage - 1) * $perPage, $perPage),
count($rows),
$perPage,
$currentPage,
['path' => request()->url(), 'query' => request()->query()],
),
'keyword' => $keyword,
'actions' => url('/' . (company_id() ?: 1) . '/workspace/' . $sectionSlug . '/actions/' . $dataset),
];
}
/** @return array{0:LengthAwarePaginator,1:?string} */
private function inventoryReportRows(): array
{
$page = max(1, request()->integer('page', 1));
$perPage = min(max((int) setting('default.list_limit', 25), 1), 100);
$filters = $this->inventoryReportFilters(request());
$error = null;
try {
if (! $this->thirdPartyWarehouseStorage->available()) {
$error = '进销存共享镜像尚未建立,暂时无法读取。';
} elseif (! $this->thirdPartyWarehouseStorage->allWarehousesReady()) {
$error = '进销存全仓库共享镜像正在执行首次同步,完成前不会展示不完整的单仓数据。';
} else {
$result = $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport(
$filters,
$page,
$perPage,
);
$shortNames = app(InventoryWarehouseShortNames::class);
$names = $shortNames->forCompany((int) (company_id() ?: 1));
return [new LengthAwarePaginator(
array_map(static fn (object $row): object => $shortNames->fill($row, $names), $result['data']),
$result['total'],
$perPage,
$page,
[
'path' => request()->url(),
'query' => request()->except('page'),
],
), null];
}
} catch (Throwable $exception) {
report($exception);
$error = '进销存共享镜像暂时无法读取,请稍后重试。';
}
return [new LengthAwarePaginator([], 0, $perPage, $page, [
'path' => request()->url(),
'query' => request()->except('page'),
]), $error];
}
private function inventoryWarehouseMappingRows(): LengthAwarePaginator
{
$query = InventoryWarehouseNameMapping::query()->orderBy('warehouse_provider')->orderBy('warehouse');
$provider = mb_substr(trim((string) request('mapping_warehouse_provider', '')), 0, 191);
$warehouse = mb_substr(trim((string) request('mapping_warehouse', '')), 0, 191);
if ($provider !== '') {
$query->where('warehouse_provider', 'like', '%' . addcslashes($provider, '%_\\') . '%');
}
if ($warehouse !== '') {
$query->where('warehouse', 'like', '%' . addcslashes($warehouse, '%_\\') . '%');
}
return $query->paginate(50)->withQueryString();
}
public function storeInventoryWarehouseMapping(Request $request)
{
$data = $request->validate([
'warehouse_provider' => ['required', 'string', 'max:191'],
'warehouse' => ['required', 'string', 'max:191'],
'warehouse_short_name' => ['nullable', 'string', 'max:191'],
]);
$data['warehouse_provider'] = trim($data['warehouse_provider']);
$data['warehouse'] = trim($data['warehouse']);
$data['warehouse_short_name'] = trim((string) ($data['warehouse_short_name'] ?? ''))
?: InventoryWarehouseNameMappingWorkbookImporter::suggestion($data['warehouse_provider'], $data['warehouse']);
if (InventoryWarehouseNameMapping::query()
->where('warehouse_provider', $data['warehouse_provider'])
->where('warehouse', $data['warehouse'])
->exists()) {
return back()->withInput()->withErrors(['warehouse' => '相同“仓储方 + 仓库”已存在,请使用修改功能。']);
}
InventoryWarehouseNameMapping::create($data + [
'company_id' => company_id() ?: 1,
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
return $this->inventoryWarehouseMappingRedirect()->with('success', '仓库名称映射已新增。');
}
public function updateInventoryWarehouseMapping(Request $request, int $id)
{
$mapping = InventoryWarehouseNameMapping::query()->findOrFail($id);
$data = $request->validate([
'warehouse_provider' => ['required', 'string', 'max:191'],
'warehouse' => ['required', 'string', 'max:191'],
'warehouse_short_name' => ['required', 'string', 'max:191'],
]);
$data = array_map(static fn (string $value): string => trim($value), $data);
if (InventoryWarehouseNameMapping::query()
->where('id', '!=', $mapping->id)
->where('warehouse_provider', $data['warehouse_provider'])
->where('warehouse', $data['warehouse'])
->exists()) {
return back()->withInput()->withErrors(['warehouse' => '相同“仓储方 + 仓库”已存在,不能产生重复台账。']);
}
$mapping->update($data + ['updated_by' => auth()->id()]);
return $this->inventoryWarehouseMappingRedirect()->with('success', '仓库名称映射已修改。');
}
public function previewInventoryWarehouseMappingImport(
Request $request,
InventoryWarehouseNameMappingWorkbookImporter $importer,
) {
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
]);
$preview = $importer->preview($request->file('import'));
$token = Str::random(40);
session()->put('inventory_warehouse_mapping_previews.' . $token, $preview);
return $this->inventoryWarehouseMappingRedirect()
->with('warehouse_mapping_preview', $preview + ['token' => $token]);
}
public function commitInventoryWarehouseMappingImport(
Request $request,
InventoryWarehouseNameMappingWorkbookImporter $importer,
) {
$token = (string) $request->input('preview_token');
$preview = session()->pull('inventory_warehouse_mapping_previews.' . $token);
abort_if($token === '' || ! is_array($preview), 422, '导入预览已过期,请重新上传文件。');
$result = $importer->commit($preview);
return $this->inventoryWarehouseMappingRedirect()->with(
'success',
"导入完成:新增 {$result['created']} 条,跳过 {$result['skipped']} 条重复映射。",
);
}
public function exportInventoryWarehouseMappings(Request $request): StreamedResponse
{
$query = InventoryWarehouseNameMapping::query()->orderBy('warehouse_provider')->orderBy('warehouse');
$provider = mb_substr(trim((string) $request->query('mapping_warehouse_provider', '')), 0, 191);
$warehouse = mb_substr(trim((string) $request->query('mapping_warehouse', '')), 0, 191);
if ($provider !== '') {
$query->where('warehouse_provider', 'like', '%' . addcslashes($provider, '%_\\') . '%');
}
if ($warehouse !== '') {
$query->where('warehouse', 'like', '%' . addcslashes($warehouse, '%_\\') . '%');
}
return response()->streamDownload(function () use ($query): void {
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('仓库名称映射表');
$headers = ['仓储方', '仓库', '仓库简称'];
$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],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter($headerRange);
foreach ([20, 32, 20] as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
$rowNumber = 2;
foreach ($query->cursor() as $mapping) {
$sheet->fromArray([
$mapping->warehouse_provider,
$mapping->warehouse,
$mapping->warehouse_short_name,
], null, "A{$rowNumber}");
$rowNumber++;
}
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, '仓库名称映射表_' . now()->format('Ymd_His') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store, no-cache, must-revalidate',
]);
}
private function inventoryWarehouseMappingRedirect()
{
return redirect()->route('business.pages.show', [
'company_id' => company_id() ?: 1,
'section' => 'product-inventory',
'page' => 'inventory-report',
'mapping' => 1,
'mapping_warehouse_provider' => request('mapping_warehouse_provider', ''),
'mapping_warehouse' => request('mapping_warehouse', ''),
]);
}
/** @return array<string,string> */
private function inventoryReportFilters(Request $request): array
{
$month = trim((string) $request->query('month', now()->startOfMonth()->subMonth()->format('Y-m')));
if (preg_match('/^(\d{4})-(\d{2})$/', $month, $matches) !== 1
|| ! checkdate((int) $matches[2], 1, (int) $matches[1])) {
$month = now()->startOfMonth()->subMonth()->format('Y-m');
}
$minimumMonth = substr(ThirdPartyWarehouseStorageMirrorRepository::INVENTORY_REPORT_START_DATE, 0, 7);
if ($month < $minimumMonth) {
$month = $minimumMonth;
}
return [
'month' => $month,
'product_code' => mb_substr(trim((string) $request->query('product_code', '')), 0, 100),
'warehouse_provider' => mb_substr(trim((string) $request->query('warehouse_provider', '')), 0, 191),
'category' => mb_substr(trim((string) $request->query('category', '')), 0, 191),
];
}
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()],
);
}
protected function agreementCourierNames(?string $page): Collection
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
return collect($this->courierInformation->names());
} catch (Throwable $exception) {
// 快递镜像不可用时仍允许打开签署页,并明确展示暂无可选资料。
report($exception);
return collect();
}
}
/** @return array<string,string> */
protected function agreementCourierAccounts(?string $page, ?Collection $courierNames = null): array
{
if ($page !== 'agreement-signing') {
return [];
}
try {
return $this->courierInformation->signingAccountsByName(
($courierNames ?? $this->agreementCourierNames($page))->values()->all(),
);
} catch (Throwable $exception) {
// 快递镜像不可用时仍可手工填写 E 签宝账号,不能阻断签署页。
report($exception);
return [];
}
}
protected function agreementTasks(?string $page): Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
$type = $this->agreementFilterType();
if ($type === 'quote-confirmation') {
return collect();
}
$query = CourierAgreementSignature::query();
$search = $this->agreementFilterSearch();
$status = $this->agreementFilterStatus();
if ($search !== '') {
$like = '%' . addcslashes($search, '%_\\') . '%';
$query->where(function ($builder) use ($like): void {
$builder
->where('business_no', 'like', $like)
->orWhere('courier_name', 'like', $like)
->orWhere('organization_name', 'like', $like)
->orWhere('operator_name', 'like', $like)
->orWhere('company_full_name', 'like', $like);
});
}
if ($status !== null) {
$query->where('status', $status);
}
if ($type !== null) {
$query->where('document_type', $type);
}
return $query
->latest('id')
->paginate(50, ['*'], 'agreement_task_page')->withQueryString();
} catch (QueryException $exception) {
// 迁移尚未执行时仍允许打开页面;发送接口会明确提示数据库未就绪。
report($exception);
return collect();
}
}
protected function agreementFilterSearch(): string
{
return mb_substr(trim((string) request('agreement_search', '')), 0, 191);
}
protected function agreementFilterStatus(): ?string
{
$status = trim((string) request('agreement_status', ''));
$allowed = [
CourierAgreementSignature::STATUS_PREPARED,
CourierAgreementSignature::STATUS_SENDING,
CourierAgreementSignature::STATUS_SIGNING,
CourierAgreementSignature::STATUS_COMPLETED,
CourierAgreementSignature::STATUS_ARCHIVED,
CourierAgreementSignature::STATUS_REVOKED,
CourierAgreementSignature::STATUS_EXPIRED,
CourierAgreementSignature::STATUS_REJECTED,
CourierAgreementSignature::STATUS_FAILED,
CourierAgreementSignature::STATUS_UNKNOWN,
];
return in_array($status, $allowed, true) ? $status : null;
}
protected function agreementFilterType(): ?string
{
$type = trim((string) request('agreement_type', ''));
$allowed = array_keys(CourierAgreementSignature::DOCUMENTS);
return in_array($type, $allowed, true) ? $type : null;
}
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 agreementQuoteSignatures(?string $page): Collection|\Illuminate\Contracts\Pagination\LengthAwarePaginator
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
$type = $this->agreementFilterType();
if ($type !== null && $type !== 'quote-confirmation') {
return collect();
}
$query = CourierQuoteSignature::query()->with('version');
$search = $this->agreementFilterSearch();
$status = $this->agreementFilterStatus();
if ($search !== '') {
$like = '%' . addcslashes($search, '%_\\') . '%';
$query->where(function ($builder) use ($like): void {
$builder
->where('organization_name', 'like', $like)
->orWhere('operator_name', 'like', $like)
->orWhereHas('version', function ($versionQuery) use ($like): void {
$versionQuery
->where('version_code', 'like', $like)
->orWhere('carrier_name', 'like', $like);
});
});
}
if ($status !== null) {
$query->where('status', $status);
}
return $query
->latest('id')
->paginate(50, ['*'], 'agreement_quote_page')->withQueryString();
} catch (QueryException $exception) {
// 报价签署迁移尚未执行时仍允许打开协议签署页。
report($exception);
return collect();
}
}
protected function agreementQuoteVersions(?string $page): Collection
{
if ($page !== 'agreement-signing') {
return collect();
}
try {
return CourierQuoteVersion::query()
->where('status', 'approved')
->orderByDesc('effective_date')
->orderByDesc('id')
->limit(100)
->get();
} catch (QueryException $exception) {
// 报价版本迁移尚未执行时仍允许打开协议签署页,报价确认单会提示暂无可选版本。
report($exception);
return collect();
}
}
protected function resolveContentForSection(string $section, string $page, ?string $tab): ?array
{
if (in_array($section, self::PAYABLES_FINAL_SECTIONS, true)) {
return null;
}
return $this->resolveContent($page, $tab);
}
protected function resolveContent(string $page, ?string $tab): ?array
{
return match ($page) {
'marketing-account-bills' => $this->contentForMarketingAccountBills($tab),
'marketing-account-balance' => $this->contentForMarketingAccountBalance($tab),
'jingxi-refund-data' => $this->contentForJingxiRefundData(),
'non-jingxi-refund-data' => $this->contentForNonJingxiRefundData(),
'small-payment-work-orders' => $this->contentForSmallPaymentWorkOrders(),
'zhongcai-reconciliation' => $this->contentForZhongcaiReconciliation(),
'normal-products' => $this->contentForNormalProducts(),
'product-prices' => $this->contentForProductPrices(),
'courier-information' => $this->contentForCourierInformation(),
'freight-reconciliation' => $this->contentForFreightReconciliation(),
'third-party-warehouse-storage' => $this->contentForThirdPartyWarehouseStorage(),
'board-deductions' => $this->contentForBoardDeductions(),
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
'courier-surcharge' => $this->contentForCourierSurcharge(),
'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page),
'shipping-data' => $this->contentForShippingData(),
'withdrawal-management' => $this->contentForWithdrawalManagement(),
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
'account-mapping' => $this->contentForAccountMapping(),
'planting-details' => $this->contentForPlantingDetails(),
'jst-order-labels' => $this->contentForJstOrderLabels(),
default => null,
};
}
/**
* 账单流水数据 › 聚水潭订单标签明细。
*
* 按标签拆行展示(一个标签一行),不做标签汇总、不对订单去重。
*/
protected function contentForJstOrderLabels(): array
{
$request = request();
$filters = $this->jstOrderLabelFilters($request);
$query = $this->jstOrderLabelQuery($filters);
$limit = min(max((int) setting('default.list_limit', 25), 10), 100);
$page = max((int) $request->query('page', 1), 1);
$hasFilters = collect($filters)->contains(static fn (string $value): bool => $value !== '');
// 无筛选时复用缓存总数,避免每次翻页都在 233 万行上重跑 COUNT(*)。
$total = $hasFilters
? (clone $query)->count()
: app(\App\Business\JstOrderLabelOptions::class)->total((int) company_id());
$records = new \Illuminate\Pagination\LengthAwarePaginator(
$query->orderByDesc('ordered_at')->orderByDesc('id')->forPage($page, $limit)->get(),
$total,
$limit,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
return [
'view' => 'business.pages.billing-transactions.jst-order-labels',
'records' => $records,
'filters' => $filters,
'totalRows' => $records->total(),
'filterOptions' => [
'shops' => app(\App\Business\JstOrderLabelOptions::class)->shops((int) company_id()),
'labels' => app(\App\Business\JstOrderLabelOptions::class)->labels((int) company_id()),
],
];
}
/** @return array<string,string> */
private function jstOrderLabelFilters(Request $request): array
{
return [
'date_from' => $this->dateFilter($request->query('date_from')),
'date_to' => $this->dateFilter($request->query('date_to')),
'shop_name' => mb_substr(trim((string) $request->query('shop_name')), 0, 191),
'label' => mb_substr(trim((string) $request->query('label')), 0, 191),
'keyword' => mb_substr(trim((string) $request->query('keyword')), 0, 100),
];
}
/** @param array<string,string> $filters */
private function jstOrderLabelQuery(array $filters): \Illuminate\Database\Eloquent\Builder
{
$query = \App\Models\Business\JstOrderLabel::query()->where('company_id', company_id());
if ($filters['date_from'] !== '') {
$query->where('ordered_at', '>=', $filters['date_from'] . ' 00:00:00');
}
if ($filters['date_to'] !== '') {
$query->where('ordered_at', '<', date('Y-m-d', strtotime($filters['date_to'] . ' +1 day')) . ' 00:00:00');
}
if ($filters['shop_name'] !== '') {
$query->where('shop_name', $filters['shop_name']);
}
if ($filters['label'] !== '') {
$query->where('label', 'like', '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['label']) . '%');
}
if ($filters['keyword'] !== '') {
$query->where('order_no', 'like', '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['keyword']) . '%');
}
return $query;
}
/** 账单流水数据 › 聚水潭订单标签明细导出(按当前筛选,全量) */
public function exportJstOrderLabels(Request $request): StreamedResponse
{
$query = $this->jstOrderLabelQuery($this->jstOrderLabelFilters($request));
return response()->streamDownload(function () use ($query): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建聚水潭订单标签明细导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, ['线上订单号', '店铺名称', '下单时间', '标签']);
$query->orderBy('id')->chunkById(2000, function ($rows) use ($csv): void {
foreach ($rows as $row) {
fputcsv($csv, [
$row->order_no,
$row->shop_name,
$row->ordered_at?->format('Y-m-d H:i:s'),
$row->label,
]);
}
});
fclose($csv);
}, '聚水潭订单标签明细_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/**
* 账单流水数据 › 种菜明细表。
*/
protected function contentForPlantingDetails(): array
{
$request = request();
$filters = $this->plantingDetailFilters($request);
$query = $this->plantingDetailQuery($filters);
$summaryRow = (clone $query)->selectRaw(
'COUNT(*) AS rows_count, COALESCE(SUM(pieces), 0) AS pieces_total, '
. 'COALESCE(SUM(finance_selling_price), 0) AS selling_price_total, '
. 'COALESCE(SUM(finance_payment_amount), 0) AS payment_amount_total, '
. 'COALESCE(SUM(deduction), 0) AS deduction_total, '
. 'COALESCE(SUM(jingxi_price_diff), 0) AS jingxi_diff_total'
)->first();
$limit = min(max((int) setting('default.list_limit', 25), 10), 100);
$records = (clone $query)
->orderByDesc('bill_date')
->orderByDesc('id')
->paginate($limit)
->withQueryString();
return [
'view' => 'business.pages.billing-transactions.planting-details',
'records' => $records,
'filters' => $filters,
'summary' => [
'rows' => (int) ($summaryRow->rows_count ?? 0),
'pieces' => (int) ($summaryRow->pieces_total ?? 0),
'selling_price' => (float) ($summaryRow->selling_price_total ?? 0),
'payment_amount' => (float) ($summaryRow->payment_amount_total ?? 0),
'deduction' => (float) ($summaryRow->deduction_total ?? 0),
'jingxi_diff' => (float) ($summaryRow->jingxi_diff_total ?? 0),
],
'filterOptions' => [
'stores' => \App\Models\Business\PlantingDetail::query()->where('company_id', company_id())
->whereNotNull('store')->distinct()->orderBy('store')->limit(500)->pluck('store')->all(),
'owners' => \App\Models\Business\PlantingDetail::query()->where('company_id', company_id())
->whereNotNull('owner')->distinct()->orderBy('owner')->limit(500)->pluck('owner')->all(),
'types' => \App\Models\Business\PlantingDetail::query()->where('company_id', company_id())
->whereNotNull('type')->distinct()->orderBy('type')->limit(200)->pluck('type')->all(),
'tags' => \App\Models\Business\PlantingDetail::query()->where('company_id', company_id())
->whereNotNull('tag')->distinct()->orderBy('tag')->limit(200)->pluck('tag')->all(),
],
];
}
/** @return array<string,string> 种菜明细表筛选条件 */
private function plantingDetailFilters(Request $request): array
{
return [
'date_from' => $this->dateFilter($request->query('date_from')),
'date_to' => $this->dateFilter($request->query('date_to')),
'store' => mb_substr(trim((string) $request->query('store')), 0, 191),
'owner' => mb_substr(trim((string) $request->query('owner')), 0, 191),
'type' => mb_substr(trim((string) $request->query('type')), 0, 191),
'tag' => mb_substr(trim((string) $request->query('tag')), 0, 191),
'keyword' => mb_substr(trim((string) $request->query('keyword')), 0, 191),
];
}
/** @param array<string,string> $filters */
private function plantingDetailQuery(array $filters): \Illuminate\Database\Eloquent\Builder
{
$query = \App\Models\Business\PlantingDetail::query()->where('company_id', company_id());
if ($filters['date_from'] !== '') {
$query->where('bill_date', '>=', $filters['date_from']);
}
if ($filters['date_to'] !== '') {
$query->where('bill_date', '<=', $filters['date_to']);
}
foreach (['store', 'owner', 'type', 'tag'] as $field) {
if ($filters[$field] !== '') {
$query->where($field, $filters[$field]);
}
}
if ($filters['keyword'] !== '') {
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $filters['keyword']) . '%';
$query->where(function ($sub) use ($like): void {
$sub->where('order_no', 'like', $like)
->orWhere('remark', 'like', $like)
->orWhere('account', 'like', $like)
->orWhere('external_id', 'like', $like)
->orWhere('creator', 'like', $like);
});
}
return $query;
}
/** 账单流水数据 › 种菜明细表导出(按当前筛选,全量) */
public function exportPlantingDetails(Request $request): StreamedResponse
{
$query = $this->plantingDetailQuery($this->plantingDetailFilters($request));
return response()->streamDownload(function () use ($query): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建种菜明细导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, [
'日期', '订单编号', '财务售价', '财务运费', '财务佣金', '京喜差价', '扣款',
'财务空包费', '财务打款金额', '笔数', '备注', '正确店铺', '负责人', '账户',
'类型', '创建人', '标签', '所属利润中心', '负责平台', 'ID',
]);
$query->orderBy('id')->chunkById(2000, function ($rows) use ($csv): void {
foreach ($rows as $row) {
fputcsv($csv, [
$row->bill_date?->toDateString(),
$row->order_no,
$row->finance_selling_price,
$row->finance_freight,
$row->finance_commission,
$row->jingxi_price_diff,
$row->deduction,
$row->finance_empty_package_fee,
$row->finance_payment_amount,
$row->pieces,
$row->remark,
$row->store,
$row->owner,
$row->account,
$row->type,
$row->creator,
$row->tag,
$row->profit_center,
$row->responsible_platform,
$row->external_id,
]);
}
});
fclose($csv);
}, '种菜明细表_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/**
* 财务规则 · 科目映射表
*
* 表里存的是「科目对 + 方向基准」,不存最终借贷方向;记账时按账单实际收支代入。
* 页面支持导入追加、单条修改、新增、删除,并实时给出账套科目校验结果。
*/
protected function contentForAccountMapping(): array
{
$store = app(AccountMappingStore::class);
$validator = app(AccountMappingValidator::class);
$rows = [];
$summary = [
'total' => 0, 'error_rows' => 0, 'warning_rows' => 0, 'ok_rows' => 0,
'error_count' => 0, 'warning_count' => 0, 'tip_count' => 0,
];
$options = ['platforms' => [], 'categories' => [], 'basis' => [], 'invoices' => []];
$error = null;
try {
$rules = $store->all();
$validated = $validator->validate($rules);
foreach ($rules as $rule) {
$rows[] = $rule + ['issues' => $validated['issues'][(int) $rule['id']] ?? []];
}
$summary = $validated['summary'];
$options = $store->options();
} catch (Throwable $exception) {
$error = '映射表存储暂时读不出来,请稍后重试。';
}
return [
'view' => 'business.pages.financial-rules.account-mapping',
'amRows' => $rows,
'amSummary' => $summary,
'amOptions' => $options,
'amEndpoint' => $this->accountMappingEndpoint(),
'amError' => $error,
];
}
/** 维护接口前缀(去掉末尾的 /rules) */
protected function accountMappingEndpoint(): string
{
try {
$url = route('business.financial-rules.account-mapping.actions.rules');
} catch (Throwable) {
$url = url((int) (company_id() ?: 1) . '/workspace/financial-rules/account-mapping/actions/rules');
}
return (string) preg_replace('#/rules$#', '', $url);
}
protected function contentForCourierPayableSummary(string $page): array
{
$store = app(CourierPayableSummaryStore::class);
$snapshot = null;
$error = null;
try {
$snapshot = $store->snapshot((int) (company_id() ?: 1), $page);
} catch (RuntimeException) {
$error = '汇总数据暂时无法读取,请重新导入后再试。';
}
$headers = $snapshot['headers'] ?? [];
$allRows = collect($snapshot['rows'] ?? []);
$filter = app(CourierPayableSummaryFilter::class);
$filters = $filter->fromRequest(request());
$filteredRows = $filter->apply($allRows, $headers, $filters);
$perPage = 50;
$lastPage = max((int) ceil($filteredRows->count() / $perPage), 1);
$currentPage = min(max((int) request()->query('page', 1), 1), $lastPage);
return [
'view' => 'business.pages.courier-payables.summary-table',
'courierSummaryPage' => $page,
'courierSummaryTitle' => $store->title($page),
'courierSummaryHeaders' => $headers,
'courierSummaryRows' => new LengthAwarePaginator(
$filteredRows->forPage($currentPage, $perPage)->values(),
$filteredRows->count(),
$perPage,
$currentPage,
['path' => request()->url(), 'query' => array_filter(
$filters,
static fn (string $value): bool => $value !== '',
)],
),
'courierSummaryFilters' => $filters,
'courierSummaryHasFilters' => $filter->hasFilters($filters),
'courierSummarySourceTotal' => $allRows->count(),
'courierSummarySourceName' => $snapshot['source_name'] ?? null,
'courierSummaryImportedAt' => $snapshot['imported_at'] ?? null,
'courierSummaryError' => $error,
];
}
/**
* Consolidated, non-destructive view of the withdrawal configuration kept
* with each store receiving account. Updates remain in the source page so
* a withdrawal action cannot delete an entire store-account record.
*/
protected function contentForWithdrawalManagement(): array
{
$report = $this->withdrawalManagementReport->results(request());
$withdrawalRows = $report['rows'];
$page = max((int) request()->query('page', 1), 1);
$perPage = 50;
$withdrawalRows = new LengthAwarePaginator(
$withdrawalRows->forPage($page, $perPage)->values(),
$withdrawalRows->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
);
return [
'view' => 'business.pages.fund-management.withdrawal-management',
'withdrawalRows' => $withdrawalRows,
'withdrawalFilters' => $report['filters'],
'withdrawalPlatforms' => $report['platforms'],
'withdrawalPaths' => $report['paths'],
'withdrawalStoreEntities' => $report['storeEntities'],
'withdrawalBalances' => $report['balances'],
'withdrawalReserveStandards' => $report['reserveStandards'],
'withdrawalAmounts' => $report['withdrawalAmounts'],
'withdrawalCompliance' => $report['compliance'],
'withdrawalBalanceSummary' => $report['summary'],
'withdrawalBalanceColumnLabel' => $report['balanceColumnLabel'],
'withdrawalBalanceDate' => $report['balanceDate'],
'withdrawalSort' => $report['sort'],
];
}
/**
* 快递附加费报价维护。
*
* 报价维护与后续附加费结算分开:本页只负责渠道、类型、结算方式、
* 计价标准和生效时间的维护展示,结算页将来根据这些规则读取业务镜像。
*/
protected function contentForShippingData(): array
{
$request = request();
$filters = $this->shippingDataFilters($request);
$mode = $request->query('mode') === 'detail' ? 'detail' : 'merged';
$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'),
]);
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null];
$error = null;
try {
$result = $this->shippingOrders->paginate($filters, $page, $limit, $mode);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => $request->url(), 'query' => $request->except('page')],
);
$sync = $this->shippingOrders->syncStatus();
} catch (Throwable $exception) {
report($exception);
$error = '发货数据暂时无法读取(可能尚未运行迁移或同步)。';
}
return [
'view' => 'business.pages.courier-payables.shipping-data',
'shippingRows' => $rows,
'shippingFilters' => $filters,
'shippingSync' => $sync,
'shippingError' => $error,
'shippingMode' => $mode,
'shippingExportUrl' => route('business.courier-payables.shipping-data.export'),
];
}
public function exportShippingData(Request $request): StreamedResponse
{
$filters = $this->shippingDataFilters($request);
$mode = $request->query('mode') === 'detail' ? 'detail' : 'merged';
$columns = $this->shippingDataColumns();
return response()->streamDownload(function () use ($filters, $columns, $mode): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建发货数据导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($this->shippingOrders->exportRows($filters, 1000, $mode) as $row) {
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['field']);
// 发货数据导出:标识列(快递单号/交易单号/商品ID/条形码)按原样文本导出,不加 ' 前缀
return match ($column['type']) {
'number' => $value === null || $value === '' ? '' : $value,
default => $this->csvText($value),
};
}, $columns));
}
fclose($csv);
}, '发货数据' . ($mode === 'detail' ? '明细' : '合并') . '_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
public function suggestShippingData(Request $request): \Illuminate\Http\JsonResponse
{
$allowed = ['supplier_name', 'shop_name', 'warehouse_name', 'express_company'];
$field = (string) $request->query('field', '');
if (! in_array($field, $allowed, true)) {
return response()->json(['items' => []]);
}
$keyword = mb_substr(trim((string) $request->query('q', '')), 0, 64);
$limit = min(max((int) $request->query('limit', 20), 1), 500);
try {
$items = $this->shippingOrders->distinctValues($field, $keyword, $limit);
} catch (Throwable $exception) {
report($exception);
$items = [];
}
return response()->json(['items' => array_values($items)]);
}
/** @return array<string,mixed> */
private function shippingDataFilters(Request $request): array
{
$text = static fn (string $key, int $length = 128): string => mb_substr(
trim((string) $request->query($key, '')),
0,
$length,
);
// 多选字段:接收 name[] 数组(也兼容单个字符串),去重、限长、限个数。
$multi = static function (string $key, int $length = 191, int $max = 50) use ($request): array {
$raw = $request->query($key, []);
if (is_string($raw)) {
$raw = $raw === '' ? [] : [$raw];
}
if (! is_array($raw)) {
$raw = [];
}
$out = [];
foreach ($raw as $value) {
$value = mb_substr(trim((string) $value), 0, $length);
if ($value !== '' && ! in_array($value, $out, true)) {
$out[] = $value;
}
if (count($out) >= $max) {
break;
}
}
return $out;
};
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1 ? $value : '';
};
return [
'express_no' => $text('express_no', 80),
'sourceno' => $text('sourceno', 80),
'barcode' => $text('barcode'),
'supplier_name' => $multi('supplier_name'),
'shop_name' => $multi('shop_name', 255),
'warehouse_name' => $multi('warehouse_name'),
'express_company' => $multi('express_company'),
'ship_from' => $date($request->query('ship_from', '')),
'ship_to' => $date($request->query('ship_to', '')),
];
}
/** @return list<array{field:string,label:string,type:string}> */
private function shippingDataColumns(): array
{
return [
['field' => 'express_no', 'label' => '快递单号', 'type' => 'identifier'],
['field' => 'trade_no', 'label' => '交易单号', 'type' => 'identifier'],
['field' => 'item_id', 'label' => '商品ID', 'type' => 'identifier'],
['field' => 'barcode', 'label' => '条形码', 'type' => 'identifier'],
['field' => 'product_name', 'label' => '商品名称', 'type' => 'text'],
['field' => 'qty', 'label' => '数量', 'type' => 'number'],
['field' => 'pkg_count', 'label' => '件数', 'type' => 'text'],
['field' => 'receiver_state', 'label' => '省', 'type' => 'text'],
['field' => 'buyer_nick', 'label' => '买家昵称', 'type' => 'text'],
['field' => 'receiver_name', 'label' => '收货姓名', 'type' => 'text'],
['field' => 'shop_name', 'label' => '店铺名称', 'type' => 'text'],
['field' => 'express_name1', 'label' => '快递名称1', 'type' => 'text'],
['field' => 'warehouse_name', 'label' => '仓库', 'type' => 'text'],
['field' => 'ship_time', 'label' => '发货时间', 'type' => 'text'],
['field' => 'freight', 'label' => '运费', 'type' => 'number'],
['field' => 'online_weight', 'label' => '线上重量', 'type' => 'number'],
['field' => 'actual_weight', 'label' => '实称重量', 'type' => 'number'],
['field' => 'length_cm', 'label' => '长(cm)', 'type' => 'number'],
['field' => 'width_cm', 'label' => '宽(cm)', 'type' => 'number'],
['field' => 'height_cm', 'label' => '高(cm)', 'type' => 'number'],
['field' => 'volume_cm3', 'label' => '体积(cm³)', 'type' => 'number'],
['field' => 'receiver_city', 'label' => '市', 'type' => 'text'],
['field' => 'receiver_district', 'label' => '区', 'type' => 'text'],
['field' => 'supplier_name', 'label' => '供应商', 'type' => 'text'],
['field' => 'receiver_address', 'label' => '收货详细地址', 'type' => 'text'],
['field' => 'receiver_mobile', 'label' => '手机号码', 'type' => 'identifier'],
['field' => 'receiver_phone', 'label' => '座机号码', 'type' => 'identifier'],
['field' => 'express_name2', 'label' => '快递名称2', 'type' => 'text'],
['field' => 'platform', 'label' => '平台', 'type' => 'text'],
['field' => 'buyer_memo', 'label' => '买家留言', 'type' => 'text'],
['field' => 'seller_memo', 'label' => '卖家备注', 'type' => 'text'],
['field' => 'package_no', 'label' => '配货单号', 'type' => 'identifier'],
['field' => 'amount', 'label' => '金额', 'type' => 'number'],
['field' => 'product_code', 'label' => '货号', 'type' => 'identifier'],
['field' => 'download_time', 'label' => '下载时间', 'type' => 'text'],
['field' => 'pay_time', 'label' => '付款时间', 'type' => 'text'],
['field' => 'freight_rate', 'label' => '费率', 'type' => 'number'],
['field' => 'freight_formula', 'label' => '公式', 'type' => 'text'],
['field' => 'weight_kg', 'label' => '重量(kg)', 'type' => 'number'],
['field' => 'settle_weight_kg', 'label' => '结算重量(kg)', 'type' => 'number'],
['field' => 'settle_volume_cm3', 'label' => '结算体积(cm³)', 'type' => 'number'],
['field' => 'recon_weight_kg', 'label' => '对账重量(kg)', 'type' => 'number'],
['field' => 'recon_volume_cm3', 'label' => '对账体积(cm³)', 'type' => 'number'],
['field' => 'finished_barcode', 'label' => '成品条码', 'type' => 'identifier'],
['field' => 'finished_name', 'label' => '成品名称', 'type' => 'text'],
['field' => 'finished_supplier', 'label' => '成品供应商', 'type' => 'text'],
['field' => 'settle_freight', 'label' => '结算运费', 'type' => 'number'],
['field' => 'front_no', 'label' => '换单前单号', 'type' => 'identifier'],
];
}
protected function contentForCourierSurcharge(): array
{
$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),
'scope' => $clean(request('scope', ''), 20),
'reconciler' => $clean(request('reconciler', ''), 40),
'supplier' => $clean(request('supplier', ''), 191),
'status' => $clean(request('status', ''), 20),
];
$data = $this->courierSurchargeQuotes->pageData($filters);
$auditReady = $this->courierSurchargeAudits->ready();
return [
'view' => 'business.pages.courier-payables.courier-surcharge',
'courierSurchargeRows' => $data['rows'],
'courierSurchargeFilters' => $filters,
'courierSurchargeSummary' => $data['summary'],
'courierSurchargeOptions' => $data['options'],
'courierSurchargeReady' => $data['ready'] && $auditReady,
'courierSurchargeDataReady' => $data['ready'],
'courierSurchargeAuditReady' => $auditReady,
'courierSurchargeTypeUsage' => $this->courierSurchargeTypes->usageCounts($data['options']['type_records'] ?? collect()),
'courierSurchargeAuditLogs' => $this->courierSurchargeAudits->recent(),
];
}
private function freightReconciliationFilters(): array
{
$request = request();
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
};
return [
'express_no' => mb_substr(trim((string) $request->query('express_no', '')), 0, 80),
'express_name' => mb_substr(trim((string) $request->query('express_name', '')), 0, 80),
'month' => mb_substr(trim((string) $request->query('month', '')), 0, 20),
'status' => mb_substr(trim((string) $request->query('status', '')), 0, 40),
'modified_from' => $date($request->query('modified_from', '')),
'modified_to' => $date($request->query('modified_to', '')),
];
}
protected function contentForFreightReconciliation(): array
{
$request = request();
$filters = $this->freightReconciliationFilters();
$page = max((int) $request->query('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$rows = new Paginator([], $limit, $page, [
'path' => $request->url(),
'query' => $request->except('page'),
]);
$state = null;
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
$summary = ['count' => null, 'freight' => null];
$error = null;
$pending = false;
try {
if (! $this->courierFreightReconciliations->available()) {
$error = '共享业务镜像尚未建立,运费对账暂时无法读取。';
} elseif (! $this->courierFreightReconciliations->ready()) {
$state = $this->courierFreightReconciliations->state();
$sync = $this->courierFreightReconciliations->syncStatus($state);
$pending = true;
$error = '运费对账镜像正在完成首次同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->courierFreightReconciliations->paginate($filters, $page, $limit);
$rows = new Paginator(
Collection::make($result['data'] ?? []),
$limit,
$page,
['path' => $request->url(), 'query' => $request->except('page')],
);
$rows->hasMorePagesWhen((bool) ($result['has_more'] ?? false));
$state = $result['state'] ?? null;
$sync = $result['sync'] ?? $sync;
$summary = $result['summary'] ?? $summary;
}
} catch (Throwable $exception) {
report($exception);
$error = '运费对账数据暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.courier-payables.freight-reconciliation',
'freightReconciliations' => $rows,
'freightReconciliationFilters' => $filters,
'freightReconciliationSummary' => $summary,
'freightReconciliationState' => $state,
'freightReconciliationSync' => $sync,
'freightReconciliationError' => $error,
'freightReconciliationPending' => $pending,
'freightReconciliationSummaryUrl' => $error === null ? $request->fullUrlWithQuery(['courier_summary' => 1, 'page' => null]) : null,
];
}
protected function contentForThirdPartyWarehouseStorage(): array
{
$request = request();
$filters = $this->thirdPartyWarehouseStorageFilters($request);
$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'),
]);
$state = null;
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
$summary = [];
$error = null;
$pending = false;
try {
if (! $this->thirdPartyWarehouseStorage->available()) {
$error = '共享业务镜像尚未建立,三方仓进销存暂时无法读取。';
} elseif (! $this->thirdPartyWarehouseStorage->ready()) {
$state = $this->thirdPartyWarehouseStorage->state();
$sync = $this->thirdPartyWarehouseStorage->syncStatus($state);
$pending = true;
$error = ($sync['code'] ?? null) === 'dependency_pending'
? '普通商品资料镜像正在执行首次全量同步,完成前不会展示口径不完整的商品属性。'
: '三方仓进销存正在执行首次全量同步,完成前不会把未同步数据误判为无数据。';
} else {
$result = $this->thirdPartyWarehouseStorage->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
[
'path' => $request->url(),
'query' => $request->except('page'),
],
);
$state = $result['state'] ?? null;
$sync = $result['sync'] ?? $sync;
$summary = $result['summary'] ?? [];
}
} catch (Throwable $exception) {
report($exception);
$error = '三方仓进销存暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.courier-payables.third-party-warehouse-storage',
'thirdPartyWarehouseRows' => $rows,
'thirdPartyWarehouseFilters' => $filters,
'thirdPartyWarehouseSummary' => $summary,
'thirdPartyWarehouseState' => $state,
'thirdPartyWarehouseSync' => $sync,
'thirdPartyWarehouseError' => $error,
'thirdPartyWarehousePending' => $pending,
'thirdPartyWarehouseExportUrl' => route('business.courier-payables.third-party-warehouse-storage.export'),
];
}
public function exportThirdPartyWarehouseStorage(Request $request): StreamedResponse
{
abort_unless(
$this->thirdPartyWarehouseStorage->available() && $this->thirdPartyWarehouseStorage->ready(),
503,
);
$filters = $this->thirdPartyWarehouseStorageFilters($request);
$columns = $this->filterExportColumns(
$this->thirdPartyWarehouseStorageExportColumns(),
$request->query('fields'),
);
$period = str_replace('-', '', $filters['period']);
return response()->streamDownload(function () use ($filters, $columns): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建三方仓进销存导出流。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
foreach ($this->thirdPartyWarehouseStorage->exportRows($filters) as $row) {
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['field']);
return match ($column['type'] ?? 'text') {
'number', 'money' => $value === null || $value === '' ? '' : $value,
'code' => $this->csvIdentifier($value),
default => $this->csvText($value),
};
}, $columns));
}
fclose($csv);
}, '三方仓进销存_' . $period . '_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
public function exportInventoryReport(Request $request): Response
{
abort_unless(
$this->thirdPartyWarehouseStorage->available()
&& $this->thirdPartyWarehouseStorage->allWarehousesReady(),
503,
);
$filters = $this->inventoryReportFilters($request);
$productInventory = collect(config('business_navigation'))->firstWhere('slug', 'product-inventory');
$inventoryPage = collect($productInventory['pages'] ?? [])->firstWhere('slug', 'inventory-report');
$availableColumns = collect($inventoryPage['directory']['fields'] ?? [])
->reject(static fn (array $column): bool => in_array($column['kind'] ?? '', ['secret', 'password'], true))
->keyBy('name');
$requestedColumnsParam = $request->query('columns', '');
abort_unless(is_string($requestedColumnsParam), 422);
$requestedColumns = trim($requestedColumnsParam);
$columns = $availableColumns->values()->all();
if ($requestedColumns !== '') {
$names = explode(',', $requestedColumns);
abort_if(count($names) > $availableColumns->count() || count($names) !== count(array_unique($names)), 422);
$columns = [];
foreach ($names as $name) {
abort_unless($availableColumns->has($name), 422);
$columns[] = $availableColumns->get($name);
}
}
$scope = $request->query('scope', 'all');
abort_unless(in_array($scope, ['all', 'page'], true), 422);
$page = max(1, $request->integer('page', 1));
$perPage = min(max((int) setting('default.list_limit', 25), 1), 100);
if ($scope === 'all' && should_queue()) {
$fileName = Str::filename('进销存报表') . '-' . now()->format('Ymd_His') . '-' . Str::random(8) . '.csv';
dispatch(new InventoryReportExport(user(), $filters, $columns, $fileName, (int) (company_id() ?: 1)))->onQueue('exports');
flash('进销存报表已提交后台生成,完成后可从通知中心下载。')->success();
return back();
}
if ($scope === 'all') {
// The local environment intentionally uses the sync queue and has
// no worker process. Defer the same export job until the response
// has been sent so a large CSV never depends on a live browser
// download connection. The job stores the completed file and
// raises the existing notification when it is ready.
$fileName = Str::filename('进销存报表') . '-' . now()->format('Ymd_His') . '-' . Str::random(8) . '.csv';
dispatch(new InventoryReportExport(user(), $filters, $columns, $fileName, (int) (company_id() ?: 1)))->afterResponse();
flash('进销存报表已提交后台生成,完成后可从通知中心下载。')->success();
return back();
}
// Build the complete CSV before opening the HTTP response. Streaming
// rows directly from the database keeps the browser request open for
// the whole aggregation and makes a slow client look like a failed
// export. A disk-backed file keeps memory bounded and lets the web
// server send a framed response with a known length.
set_time_limit(0);
$filePath = tempnam(sys_get_temp_dir(), 'inventory-report-');
if ($filePath === false) {
throw new RuntimeException('无法创建进销存报表导出临时文件。');
}
try {
$csv = fopen($filePath, 'wb');
if ($csv === false) {
throw new RuntimeException('无法写入进销存报表导出临时文件。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_column($columns, 'label'));
$rows = $scope === 'page'
? $this->thirdPartyWarehouseStorage->paginateMonthlyInventoryReport($filters, $page, $perPage, false)['data']
: $this->thirdPartyWarehouseStorage->exportMonthlyInventoryReportRows($filters);
$shortNames = app(InventoryWarehouseShortNames::class);
$names = $shortNames->forCompany((int) (company_id() ?: 1));
$written = 0;
foreach ($rows as $row) {
$row = $shortNames->fill($row, $names);
fputcsv($csv, array_map(function (array $column) use ($row): mixed {
$value = data_get($row, $column['name']);
return $column['name'] === 'product_code'
? $this->csvIdentifier($value)
: (($column['kind'] ?? null) === 'integer'
? ($value === null || $value === '' ? '' : $value)
: $this->csvText($value));
}, $columns));
$written++;
if ($written % 5000 === 0) {
fflush($csv);
gc_collect_cycles();
}
}
fclose($csv);
clearstatcache(true, $filePath);
$fileSize = filesize($filePath);
if ($fileSize === false) {
throw new RuntimeException('无法读取进销存报表导出文件大小。');
}
} catch (Throwable $exception) {
if (isset($csv) && is_resource($csv)) {
fclose($csv);
}
@unlink($filePath);
throw $exception;
}
$fileName = '进销存报表_' . str_replace('-', '', $filters['month']) . '_' . now()->format('Ymd_His') . '.csv';
return response()->streamDownload(function () use ($filePath): void {
try {
set_time_limit(0);
readfile($filePath);
} finally {
@unlink($filePath);
}
}, $fileName, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
'X-Accel-Buffering' => 'no',
'Content-Length' => (string) $fileSize,
]);
}
/** @return array<string,string> */
private function thirdPartyWarehouseStorageFilters(Request $request): array
{
$dimension = in_array((string) $request->query('dimension'), ['month', 'day'], true)
? (string) $request->query('dimension')
: 'month';
$period = trim((string) $request->query(
'period',
$dimension === 'day' ? $request->query('date', '') : $request->query('month', ''),
));
if ($dimension === 'day') {
if ($this->dateFilter($period) === '') {
$period = now()->subDay()->toDateString();
}
if ($period < ThirdPartyWarehouseStorageMirrorRepository::START_DATE) {
$period = ThirdPartyWarehouseStorageMirrorRepository::START_DATE;
}
} elseif (preg_match('/^(\d{4})-(\d{2})$/', $period, $matches) !== 1
|| ! checkdate((int) $matches[2], 1, (int) $matches[1])) {
$period = now()->startOfMonth()->subMonth()->format('Y-m');
}
if ($dimension === 'month'
&& $period < substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7)) {
$period = substr(ThirdPartyWarehouseStorageMirrorRepository::START_DATE, 0, 7);
}
$clean = static fn (mixed $value, int $length): string => mb_substr(trim((string) $value), 0, $length);
return [
'dimension' => $dimension,
'period' => $period,
'sku_code' => $clean($request->query('sku_code', ''), 80),
'product_name' => $clean($request->query('product_name', ''), 120),
'supplier' => $clean($request->query('supplier', ''), 120),
'category' => $clean($request->query('category', ''), 120),
];
}
/** @return array<int,array{label:string,field:string,type?:string}> */
private function thirdPartyWarehouseStorageExportColumns(): array
{
return [
['field' => 'owner', 'label' => '货主'],
['field' => 'date', 'label' => '日期'],
['field' => 'sku_code', 'label' => '商品编码', 'type' => 'code'],
['field' => 'image_url', 'label' => '图片'],
['field' => 'product_name', 'label' => '商品名称'],
['field' => 'short_name', 'label' => '商品简称'],
['field' => 'supplier', 'label' => '供应商'],
['field' => 'category', 'label' => '分类'],
['field' => 'virtual_category', 'label' => '虚拟分类'],
['field' => 'supplier_style_no', 'label' => '供应商款号', 'type' => 'code'],
['field' => 'color_spec', 'label' => '颜色规格'],
['field' => 'brand', 'label' => '品牌'],
['field' => 'unit', 'label' => '单位'],
['field' => 'opening_qty', 'label' => '期初数量', 'type' => 'number'],
['field' => 'closing_qty', 'label' => '期末数量', 'type' => 'number'],
['field' => 'processing_in', 'label' => '加工进仓', 'type' => 'number'],
['field' => 'processing_out', 'label' => '加工出仓', 'type' => 'number'],
['field' => 'purchase_in', 'label' => '采购进仓', 'type' => 'number'],
['field' => 'purchase_return', 'label' => '采购退货', 'type' => 'number'],
['field' => 'other_in', 'label' => '其它进仓', 'type' => 'number'],
['field' => 'other_return', 'label' => '其它退货', 'type' => 'number'],
['field' => 'sales_out', 'label' => '销售出仓', 'type' => 'number'],
['field' => 'sales_return', 'label' => '销售退货', 'type' => 'number'],
['field' => 'stocktake', 'label' => '盘点', 'type' => 'number'],
['field' => 'transfer_out', 'label' => '调拨出', 'type' => 'number'],
['field' => 'transfer_in', 'label' => '调拨入', 'type' => 'number'],
['field' => 'opening_aux', 'label' => '期初', 'type' => 'number'],
['field' => 'other_out', 'label' => '其它出库', 'type' => 'number'],
];
}
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 contentForOnlineDaifaPrice(): array
{
$filters = $this->onlineDaifaPriceFilters(request());
$page = max((int) request('page', 1), 1);
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
try {
$result = $this->onlineDaifaPrices->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.product-inventory.online-daifa-price',
'onlineDaifaRows' => $rows,
'onlineDaifaFilters' => $filters,
'onlineDaifaStatus' => $result['status'] ?? 'unknown',
'onlineDaifaSyncedAt' => $result['last_synced_at'] ?? null,
'onlineDaifaSummary' => $result['summary'] ?? [],
'onlineDaifaSources' => $result['source_statuses'] ?? [],
'onlineDaifaError' => null,
];
} catch (Throwable $exception) {
report($exception);
return [
'view' => 'business.pages.product-inventory.online-daifa-price',
'onlineDaifaRows' => new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(), 'query' => request()->except('page'),
]),
'onlineDaifaFilters' => $filters,
'onlineDaifaStatus' => 'unavailable',
'onlineDaifaSyncedAt' => null,
'onlineDaifaSummary' => [],
'onlineDaifaSources' => [],
'onlineDaifaError' => '线上代发价格镜像尚未完成同步,请稍后重试。',
];
}
}
public function exportOnlineDaifaPrice(Request $request): StreamedResponse
{
abort_unless($this->onlineDaifaPrices->ready(), 503);
$filters = $this->onlineDaifaPriceFilters($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', '商品名称(匹配)', '店铺名称',
'代发交易单号', '店铺交易单号', '发货数量', '订单实付金额', '代发订单金额', '差价',
'供应商名称', '商品编码', '创建时间',
]);
foreach ($this->onlineDaifaPrices->exportRows($filters) as $row) {
$workflow = (array) ($row['workflow'] ?? []);
$approvalNumber = trim((string) ($workflow['businessId'] ?? '')) ?: (string) ($row['process_instance_id'] ?? '');
$originator = trim((string) ($workflow['originatorUserName'] ?? ''))
?: trim((string) ($workflow['originatorUserId'] ?? ''));
$matchedProduct = (array) ($row['matched_product'] ?? []);
$productName = ($row['source_type'] ?? 'ding') === 'purchase'
? ''
: (($matchedProduct['matched'] ?? false)
? (string) ($matchedProduct['name'] ?? '')
: '(未匹配)');
fputcsv($csv, [
$this->csvIdentifier($approvalNumber),
$this->csvText($workflow['title'] ?? ''),
$this->csvText($row['status_label'] ?? ''),
$this->csvText($originator),
$this->csvText($row['payee_name'] ?? ''),
$this->csvText($row['tag'] ?? ''),
$this->csvIdentifier($row['product_id'] ?? ''),
$this->csvText($productName),
$this->csvText($row['shop_name'] ?? ''),
$this->csvIdentifier($row['daifa_trade_no'] ?? ''),
$this->csvIdentifier($row['shop_trade_no'] ?? ''),
$row['ship_qty'] ?? null,
$row['paid_amount'] ?? null,
$row['daifa_amount'] ?? null,
$row['price_diff'] ?? null,
$this->csvText($row['supplier_name'] ?? ''),
$this->csvIdentifier($row['material_no'] ?? ''),
$this->csvText($row['workflow_created_at'] ?? ''),
]);
}
fclose($csv);
}, '线上代发价格_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,string> */
private function onlineDaifaPriceFilters(Request $request): array
{
$clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
$date = static function (mixed $value): string {
$value = trim((string) $value);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
};
$status = strtoupper(mb_substr(trim((string) $request->query('status', '')), 0, 30));
return [
'keyword' => $clean($request->query('keyword', ''), 80),
'product_id' => $clean($request->query('product_id', ''), 64),
'shop_name' => $clean($request->query('shop_name', ''), 120),
'supplier_name' => $clean($request->query('supplier_name', ''), 120),
'material_no' => $clean($request->query('material_no', ''), 120),
'status' => preg_match('/^[A-Z_]+$/', $status) === 1 ? $status : '',
'created_from' => $date($request->query('created_from', '')),
'created_to' => $date($request->query('created_to', '')),
];
}
protected function contentForJingxiRefundData(): array
{
return [
'view' => 'business.pages.refunds.jingxi-refund-data',
];
}
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,
$request->query('columns'),
);
} 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, mixed $requestedFields = null)
{
$columns = [
['label' => '序号/主键ID', 'field' => 'Id', 'identifier' => true],
['label' => '退款日期', 'field' => 'refundDate'],
['label' => '售后单ID', 'field' => 'as_id', 'identifier' => true],
['label' => '外部售后单号', 'field' => 'outer_as_id', 'identifier' => true],
['label' => '订单ID', 'field' => 'o_id', 'identifier' => true],
['label' => '', 'field' => ''],
['label' => '子订单ID', 'field' => 'so_id', 'identifier' => true],
['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', 'identifier' => true],
['label' => '店铺SKU编码', 'field' => 'shop_sku_id', 'identifier' => true],
['label' => '商品ID', 'field' => 'i_id', 'identifier' => true],
['label' => 'SKU编码', 'field' => 'sku_id', 'identifier' => true],
['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'],
];
$columns = $this->filterExportColumns($columns, $requestedFields);
$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'])
: (($column['identifier'] ?? false)
? $this->csvIdentifier(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 contentForSmallPaymentWorkOrders(): array
{
$request = request();
$filters = $this->smallPaymentWorkOrderFilters($request);
$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];
$pagePaymentAmount = '0.00';
$filteredPaymentAmount = '0.00';
$shopNames = [];
$paymentReasons = [];
$error = null;
$pending = false;
$indexPending = false;
try {
if (! $this->smallPaymentWorkOrders->available()) {
$error = '共享业务镜像尚未建立,小额打款工单暂时无法读取。';
} else {
$states = $this->smallPaymentWorkOrders->states();
$syncStatus = $this->smallPaymentWorkOrders->syncStatus($states);
if (! $this->smallPaymentWorkOrders->ready()) {
$pending = true;
} elseif (! $this->smallPaymentWorkOrders->queryReady()) {
$indexPending = true;
} else {
$result = $this->smallPaymentWorkOrders->paginate($filters, $page, $limit);
$rows = new LengthAwarePaginator(
Collection::make($result['data'] ?? []),
(int) ($result['total'] ?? 0),
$limit,
$page,
['path' => $request->url(), 'query' => $request->query()],
);
$pagePaymentAmount = (string) ($result['page_payment_amount'] ?? '0.00');
$filteredPaymentAmount = (string) ($result['filtered_payment_amount'] ?? '0.00');
$filterOptions = $this->smallPaymentWorkOrders->filterOptions();
$shopNames = $filterOptions['shops'];
$paymentReasons = $filterOptions['payment_reasons'];
}
}
} catch (Throwable $exception) {
report($exception);
$error = '小额打款工单暂时无法读取,请稍后重试。';
}
return [
'view' => 'business.pages.refunds.small-payment-work-orders',
'filters' => $filters,
'rows' => $rows,
'smallPaymentSyncStatus' => $syncStatus,
'smallPaymentPageAmount' => $pagePaymentAmount,
'smallPaymentFilteredAmount' => $filteredPaymentAmount,
'smallPaymentShops' => $shopNames,
'smallPaymentReasons' => $paymentReasons,
'smallPaymentStatuses' => [
'待审核', '审核驳回', '待确认', '确认中', '已完成', '已作废',
'支付宝已打款', '银行卡已打款', '已打款',
],
'smallPaymentError' => $error,
'smallPaymentPending' => $pending,
'smallPaymentIndexPending' => $indexPending,
];
}
public function exportSmallPaymentWorkOrders(Request $request)
{
$filters = $this->smallPaymentWorkOrderFilters($request);
$redirectParameters = array_filter([
'section' => self::SMALL_PAYMENT_SECTION,
'page' => 'small-payment-work-orders',
...$filters,
], static fn (mixed $value): bool => $value !== '' && $value !== null);
try {
if (! $this->smallPaymentWorkOrders->available()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '共享业务镜像尚未建立,小额打款工单暂时不能导出。');
}
if (! $this->smallPaymentWorkOrders->ready()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '业务镜像首次同步尚未完成,小额打款工单暂时不能导出。');
}
if (! $this->smallPaymentWorkOrders->queryReady()) {
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '页面查询索引尚未就绪,小额打款工单暂时不能导出。');
}
return $this->streamSmallPaymentWorkOrderCsv(
$this->smallPaymentWorkOrders->exportRows($filters),
$filters,
$redirectParameters,
$request->query('columns'),
);
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '小额打款工单导出暂时无法完成,请稍后重试。');
}
}
/**
* @return array{from:string,to:string,work_order_no:string,shop_name:string,transaction_no:string,online_order_no:string,status:string,payment_reason:string}
*/
private function smallPaymentWorkOrderFilters(Request $request): array
{
$defaultTo = now()->format('Y-m-d') . 'T23:59:59';
$defaultFrom = now()->subDays(29)->format('Y-m-d') . 'T00:00:00';
$rawFrom = $request->query('from', $defaultFrom);
$rawTo = $request->query('to', $defaultTo);
$from = $this->smallPaymentDateTimeFilter($rawFrom, false) ?: $defaultFrom;
$to = $this->smallPaymentDateTimeFilter($rawTo, true) ?: $defaultTo;
if ($from > $to) {
$from = $this->smallPaymentDateTimeFilter($rawTo, false) ?: $defaultFrom;
$to = $this->smallPaymentDateTimeFilter($rawFrom, true) ?: $defaultTo;
}
$text = static fn (mixed $value, int $length = 100): string => mb_substr(trim((string) $value), 0, $length);
return [
'from' => $from,
'to' => $to,
'work_order_no' => $text($request->query('work_order_no', '')),
'shop_name' => $text($request->query('shop_name', ''), 191),
'transaction_no' => $text($request->query('transaction_no', '')),
'online_order_no' => $text($request->query('online_order_no', '')),
'status' => $text($request->query('status', ''), 60),
'payment_reason' => $text($request->query('payment_reason', ''), 191),
];
}
private function smallPaymentDateTimeFilter(mixed $value, bool $upperBound): string
{
$value = trim((string) $value);
if (preg_match('/^(\d{4}-\d{2}-\d{2})$/', $value, $dateMatch) === 1) {
if ($this->dateFilter($dateMatch[1]) === '') {
return '';
}
return $dateMatch[1] . ($upperBound ? 'T23:59:59' : 'T00:00:00');
}
if (preg_match(
'/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/',
$value,
$matches,
) !== 1 || $this->dateFilter($matches[1]) === '') {
return '';
}
$hour = (int) $matches[2];
$minute = (int) $matches[3];
$second = isset($matches[4]) ? (int) $matches[4] : ($upperBound ? 59 : 0);
if ($hour > 23 || $minute > 59 || $second > 59) {
return '';
}
return sprintf('%sT%02d:%02d:%02d', $matches[1], $hour, $minute, $second);
}
private function streamSmallPaymentWorkOrderCsv(iterable $rows, array $filters, array $redirectParameters, mixed $requestedFields = null)
{
$columns = [
['label' => '工单编号', 'field' => 'work_order_no', 'identifier' => true],
['label' => '支付宝批次号', 'field' => 'alipay_batch_no', 'identifier' => true],
['label' => '工单状态', 'field' => 'work_order_status'],
['label' => '打款状态', 'field' => 'payment_status'],
['label' => '工单标题', 'field' => 'work_order_title'],
['label' => '工单内容', 'field' => 'work_order_content'],
['label' => '工单类型', 'field' => 'work_order_type'],
['label' => '优先级', 'field' => 'priority'],
['label' => '打款金额', 'field' => 'payment_amount', 'number' => true],
['label' => '催处理次数', 'field' => 'urging_count', 'number' => true],
['label' => '店铺名称', 'field' => 'shop_name'],
['label' => '线上订单号', 'field' => 'online_order_no', 'identifier' => true],
['label' => '买家账号', 'field' => 'buyer_account'],
['label' => '支付宝账号', 'field' => 'alipay_account'],
['label' => '支付宝姓名', 'field' => 'alipay_name'],
['label' => '商品编码', 'field' => 'product_code', 'identifier' => true],
['label' => 'SKU编码', 'field' => 'sku_code', 'identifier' => true],
['label' => '平台', 'field' => 'platform'],
['label' => '实际已付金额', 'field' => 'actual_paid_amount', 'number' => true],
['label' => '支付时间', 'field' => 'payment_time'],
['label' => '打款原因', 'field' => 'payment_reason'],
['label' => '创建人', 'field' => 'creator'],
['label' => '创建时间', 'field' => 'work_order_created_at'],
['label' => '完成时间', 'field' => 'completed_at'],
];
$columns = $this->filterExportColumns($columns, $requestedFields);
$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['number'] ?? false)
? data_get($row, $column['field'])
: (($column['identifier'] ?? false)
? $this->csvIdentifier(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', '小额打款工单导出暂时无法完成,请稍后重试。');
}
$filenameFrom = str_replace(['T', ':'], ['_', '-'], $filters['from']);
$filenameTo = str_replace(['T', ':'], ['_', '-'], $filters['to']);
return response()->streamDownload(function () use ($csv): void {
fpassthru($csv);
fclose($csv);
}, 'small_payment_work_orders_' . $filenameFrom . '_' . $filenameTo . '.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', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], 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';
$isAlibaba = $tab === 'taobao-wechat';
$isJd = $tab === 'jd-wallet';
$repository = match ($tab) {
'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class),
'taobao-wechat' => app(AlibabaAccountBalanceRepository::class),
'jd-wallet' => app(JdPromotionAccountBalanceRepository::class),
default => app(PinduoduoSpreadBillRepository::class),
};
$scope = $isTaobao ? 'taobao-alipay-bills.list' : 'pinduoduo-bills.list';
$cache = ($isAlibaba || $isJd) ? null : $this->upstreamCaches->forSource('jiansun');
$usingMirror = $repository->usesBusinessMirror();
try {
if ($isJd && ! $usingMirror) {
throw new ServiceUnavailableHttpException(null, '京东账户余额共享业务镜像尚未完成同步,请稍后重试。');
}
$resolver = fn () => $repository->paginateMonthlyBalances(
$filters['store_name'],
$filters['month'],
$page,
$pageSize,
);
$result = ($usingMirror || $isAlibaba || $isJd)
? $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);
$platformName = $isTaobao ? '淘系' : ($isAlibaba ? '阿里巴巴' : ($isJd ? '京东' : '拼多多'));
$content['error'] = $platformName . '账户余额数据暂时无法读取,请稍后重试。';
$result = ['rows' => [], 'total' => 0];
}
try {
$resolver = fn () => $repository->storeOptions();
$storeOptions = ($usingMirror || $isAlibaba || $isJd)
? $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(in_array($section, ['billing-transactions', self::ACCOUNT_BALANCE_SECTION], true), 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, '当前账号没有该页面权限。');
abort_unless(collect($pageConfig['tabs'] ?? [])->pluck('slug')->contains($tab), 404);
if ($page === 'marketing-account-bills') {
abort_unless($section === 'billing-transactions', 404);
if (in_array($tab, self::PROMOTION_MARKETING_TABS, true)) {
return $this->exportPlatformPromotionAccounts($request, $section, $page, $tab);
}
return $this->exportMarketingAccountBills($request, $section, $page, $tab);
}
abort_unless($section === self::ACCOUNT_BALANCE_SECTION && $page === 'marketing-account-balance', 404);
abort_unless(in_array($tab, ['taobao-alipay', 'taobao-wechat', 'pinduoduo', 'jd-wallet'], 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';
$isAlibaba = $tab === 'taobao-wechat';
$isJd = $tab === 'jd-wallet';
$repository = match ($tab) {
'taobao-alipay' => app(TmallTaobaoSpreadBillRepository::class),
'taobao-wechat' => app(AlibabaAccountBalanceRepository::class),
'jd-wallet' => app(JdPromotionAccountBalanceRepository::class),
default => 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 {
$usingMirror = $repository->usesBusinessMirror();
if ($isJd && ! $usingMirror) {
throw new ServiceUnavailableHttpException(null, '京东账户余额共享业务镜像尚未完成同步。');
}
$resolver = fn () => $repository->paginateMonthlyBalances($filters['store_name'], $filters['month'], 1, 5000);
$result = ($usingMirror || $isAlibaba || $isJd)
? $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', $isJd
? '京东账户余额共享业务镜像尚未完成同步,暂时不能导出;请稍后重试。'
: '首份共享快照正在准备,系统已登记刷新请求;请稍后刷新页面后再导出。');
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', $redirectParameters)
->with('error', '余额数据导出暂时无法完成,请稍后重试。');
}
$rows = $result['rows'] ?? [];
$filePrefix = $isTaobao ? 'taobao' : ($isAlibaba ? 'alibaba' : ($isJd ? 'jd' : 'pinduoduo'));
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);
}, $filePrefix . '_account_balances_' . ($month !== '' ? $month : 'all') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
private function exportPlatformPromotionAccounts(
Request $request,
string $section = 'billing-transactions',
string $page = 'marketing-account-bills',
?string $tab = null,
)
{
$platform = PlatformPromotionAccountPageData::platformForTab($tab);
abort_if($platform === null, 404);
$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),
];
if ($platform === 'vip') {
$filters['advertiser_id'] = mb_substr(trim((string) $request->query('advertiser_id', '')), 0, 100);
}
try {
$repository = app(\Modules\EcommerceFinance\Repositories\PlatformPromotionAccountRepository::class);
$filters['month'] = $month ?: ($repository->latestMonth() ?: now()->format('Y-m'));
$data = $repository->dashboard($filters, 5000, $platform);
} catch (Throwable $exception) {
report($exception);
return redirect()->route('business.pages.show', array_filter([
'section' => $section,
'page' => $page,
'tab' => $tab,
], static fn (mixed $value): bool => $value !== null && $value !== ''))
->with('error', '推广账户数据导出暂时无法完成,请稍后重试。');
}
return response()->streamDownload(function () use ($data, $platform): void {
$csv = fopen('php://output', 'w');
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
if ($platform === 'vip') {
fputcsv($csv, [
'平台', '营销账户', '账户名称', '广告主 ID', '广告主名称',
'报表日期', '花费(元)', '曝光量', '更新时间',
]);
foreach ($data['vipMarketingDaily'] ?? [] as $row) {
fputcsv($csv, [
'唯品会推广流水',
$row['account'] ?? '',
$row['accountName'] ?? '',
$row['advertiserId'] ?? '',
$row['advertiserTitle'] ?? '',
$row['reportDate'] ?? '',
$row['costYuan'] ?? '',
$row['impressionCount'] ?? '',
$row['updatedAt'] ?? '',
]);
}
fclose($csv);
return;
}
if ($platform === 'jd') {
fputcsv($csv, [
'平台', '店铺', '账户', '日期', '记账时间', '收支类型', '交易类型',
'金额(元)', '余额(元)', '备注',
]);
foreach ($data['jd'] ?? [] as $row) {
fputcsv($csv, [
'京东京准通',
$row['shop_name'] ?? '',
$row['account'] ?? '',
$row['show_date'] ?? '',
$row['settle_datetime'] ?? '',
$row['in_out_type_name'] ?? '',
$row['trade_type_name'] ?? '',
$row['amount'] ?? '',
$row['balance_amount'] ?? '',
$row['remark'] ?? '',
]);
}
fclose($csv);
return;
}
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),
]);
}
};
if ($platform === 'douyin') {
$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'] ?? '']);
} elseif ($platform === 'dewu') {
$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'] ?? '']);
}
fclose($csv);
}, '店铺营销账户账单_' . $platform . '_' . $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),
);
$rows = $this->supplierPayableOverrides->decorate(
'board-deductions',
(array) ($result['rows'] ?? []),
(int) (company_id() ?: 1),
);
return [
'boardDeductions' => $this->supplierPayablePaginator($rows, $limit, $page),
'boardDeductionFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'),
'boardDeductionStatus' => $cache->responseHeaders(),
'boardDeductionError' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page),
'boardDeductionFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'),
'boardDeductionStatus' => [],
'boardDeductionError' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page),
'boardDeductionFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'),
'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),
);
$rows = $this->supplierPayableOverrides->decorate(
'supplier-payables-report',
(array) ($result['rows'] ?? []),
(int) (company_id() ?: 1),
);
return [
'supplierPayables' => $this->supplierPayablePaginator($rows, $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'),
'supplierPayableStatus' => $cache->responseHeaders(),
'supplierPayableError' => null,
];
} catch (ServiceUnavailableHttpException $exception) {
return [
'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'),
'supplierPayableStatus' => [],
'supplierPayableError' => $exception->getMessage(),
];
} catch (Throwable $exception) {
report($exception);
return [
'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'),
'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,
],
'productCatalogWorkPlatformPriceSyncStatus' => $result['work_platform_price_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogPriceChangeSyncStatus' => $result['price_change_sync_status'] ?? [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogJushuitanCostPriceHistorySyncStatus' => $result['jushuitan_cost_price_history_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', '款式编码', '商品编码', '商品名称', '商品简称', '规格 / 颜色',
'品牌', '类目', '供应商', '销售价', '成本价', '普通商品采购价',
'工作平台价格', '工作平台价格生效日期', '定价状态', '变价前价格', '变价后价格', '差额', '变价时间',
'数据来源', '市场价', '商品状态', '库存同步', '业务更新时间',
]);
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->work_platform_price ?? null,
$this->workPlatformPriceDate($row->work_platform_price_effect_time ?? null),
$row->pricing_status ?? '未定价',
$row->price_change_before_price ?? null,
$row->price_change_after_price ?? null,
$row->price_change_diff_amount ?? null,
$this->workPlatformPriceDate($row->price_change_effect_time ?? null),
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',
]);
}
private function workPlatformPriceDate(mixed $value): string
{
if (! is_string($value) || trim($value) === '') {
return '';
}
try {
return Carbon::parse($value)->format('Y-m-d');
} catch (Throwable) {
return '';
}
}
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;
}
/**
* Keep client-selected exports within the fields declared by the page.
* An omitted or invalid selection falls back to the complete safe schema.
*
* @param array<int,array{label:string,field:string}> $columns
* @return array<int,array{label:string,field:string}>
*/
private function filterExportColumns(array $columns, mixed $requestedFields): array
{
if (! is_string($requestedFields) || trim($requestedFields) === '') {
return $columns;
}
$available = [];
foreach ($columns as $column) {
if ($column['field'] !== '') {
$available[$column['field']] = $column;
}
}
$selected = [];
foreach (explode(',', $requestedFields) as $field) {
$field = trim($field);
if ($field !== '' && isset($available[$field])) {
$selected[$field] = $available[$field];
}
}
return $selected !== [] ? array_values($selected) : $columns;
}
/**
* Export identifiers as text so Excel does not round or truncate long IDs.
* Keep the formula-injection guard from csvText for values beginning with a
* formula marker.
*/
private function csvIdentifier(mixed $value): string
{
$text = $this->csvText($value);
if ($text === '' || str_starts_with($text, "'")) {
return $text;
}
return "'" . $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,
],
'productCatalogWorkPlatformPriceSyncStatus' => [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogPriceChangeSyncStatus' => [
'code' => 'unavailable',
'initialized' => false,
'last_succeeded_at' => null,
],
'productCatalogJushuitanCostPriceHistorySyncStatus' => [
'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;
}
if (in_array($tab, self::PROMOTION_MARKETING_TABS, true)) {
return app(PlatformPromotionAccountPageData::class)->content($tab);
}
// 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 fundForecastTemplate(FundForecastTemplate $template): StreamedResponse
{
return $template->download();
}
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);
}
}