Compare commits

..

1 Commits

Author SHA1 Message Date
3c4f47b3fc 总账备注取科目映射表:平台/账单项目键做大小写与空格归一
科目映射表里平台写「唯品会JX」,页签内部键是「唯品会jx」,此前按原样索引匹配不到;
改为统一小写去空格后索引与查找(其它平台如唯品会MP/阿里巴巴不受影响)。
2026-10-07 15:30:45 +08:00
615 changed files with 5081 additions and 92443 deletions

View File

@ -10,7 +10,6 @@ runtime
*.md
JetBrainsMono-字体
node_modules
**/__pycache__
vendor
bootstrap/cache/*
.caiwu-build
@ -23,7 +22,6 @@ storage/debugbar
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
storage/framework/testing
storage/logs/*
tests
test-data
@ -31,6 +29,3 @@ docs
scripts
npm-debug.log
yarn-error.log
public/net-layer-demo.html
public/perf-diff-plan.html
public/plan-a-changes-202608.csv

View File

@ -38,14 +38,8 @@ jobs:
- name: Copy .env
run: cp .env.testing .env
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install NPM
run: npm ci --legacy-peer-deps --no-audit --no-fund
run: npm install
- name: Compile assets
run: npm run production

4
.gitignore vendored
View File

@ -113,7 +113,3 @@ modules/*
.workbuddy/
runtime/.env
/.private/kaifa-progress-sync/
__pycache__/
public/net-layer-demo.html
public/perf-diff-plan.html
public/plan-a-changes-202608.csv

1
.nvmrc
View File

@ -1 +0,0 @@
24.22.1

View File

@ -38,15 +38,6 @@
- 数据页面按本次影响验证代表性数据量下的首页、翻页和筛选耗时;明显长时间等待不能以“返回正确”判定通过。低风险改动不增加重复或仅复述实现的测试。
- 明确区分:本地已验证、已提交/推送、已合并、已上线和仍需生产验收。
### 性能与资源验收(强制)
- 涉及查询、报表、导入导出、同步或后台任务时,开发前先确认实际数据规模、单次数据量、文件大小和运行频率,并据此选择实现与验收数据;不能只验证几十行样例。
- Excel/CSV 必须流式读取;表扫描、关联和排序使用合适索引,页面数据使用分页。分批写库只控制写入批次,不能据此声称读取过程内存受控。
- 重型任务复用项目现有独立批处理容器,完成命令登记,并明确内存上限、超时、互斥和失败恢复;不得占用网页服务资源执行全量处理。
- 禁止通过取消 `memory_limit`、无限重试、放大数据库参数或其他绕过资源边界的办法掩盖性能问题。
- 使用代表性数据测量耗时、进程峰值内存和数据库开销,并在交付说明中写明测量范围、数据量及未验证项。生产环境不运行压测,不随意重跑全量任务。
- 性能优化不得改变金额口径、筛选范围或现有业务结果;相关金额和功能回归必须同时通过。
## 5. 前端与视觉
- 只有前端任务需要先阅读根目录 `DESIGN.md`;`.impeccable/design.json` 是同源机器规范。
@ -73,14 +64,12 @@
## 8. 构建与生产部署
- 发布脚本会拒绝覆盖“不在本次版本里”的线上提交(防止互相冲掉已上线的改动)。热修复必须基于线上当前版本构建,发布后立即合入 `main`;遇到拦截先 `git fetch` 并合入最新 `main` 或线上热修复分支,不得随手加 `-AllowRollback`(`--allow-rollback`),撤回线上发布需负责人明确决定。
- 普通代码发布由任务 Codex 在完成验证与合并后直接执行,不要求用户再次说“部署、上线”;用户明确要求仅本地开发、仅审查或暂不上线时遵循其范围。正式发布只使用已合并的最新 `main`。
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`,macOS 开发机使用 `bash scripts/deploy-from-mac.sh`;两者流程一致(本机构建前端与镜像、迁移拦截、增量推送服务器本机 Registry、激活与健康检查、双版本保留)。服务器只运行开发机已构建的 `caiwu_akaunting:<commit>` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。
- 新人只需表达部署需求;Codex 使用默认生产入口运行脚本。首次通过本机窗口提供一次服务器密码后,加密保存在本人电脑供后续自动使用,不要求新人准备私钥、执行命令或每次重输密码。已有私钥认证仍兼容。
- 当前生产入口为 `https://caiwu.jiansuntuzhi.cn:8442`;公网仍只使用 `8442`,日常发布不得改成无端口地址。
- 发布脚本检测到新增数据库迁移时,由 Codex 核对全部待执行迁移。仅涉及本任务共享镜像建表、字段、索引和同步登记的迁移,完成备份后自主使用 `-AllowMigrations`(macOS 为 `--allow-migrations`);其他迁移仍按第 6 节确认授权。不能因需要迁移开关就把镜像接入停给负责人。
- 容器内 Laravel 命令使用 `www-data`。发布后只需确认部署 SHA、应用容器健康、受影响的真实页面/接口、迁移是否执行,以及当前和上一版镜像可回滚。
- 生产重型 CLI 导入统一使用 `sh /opt/caiwuxitong/run-finance-batch.sh <命令> <原参数>`,不得在网页容器内直接执行或取消 PHP 内存限制。新增导入登记 `deploy/finance-batch-imports.json`;入口提供独立容器、互斥、资源与时间上限,主应用保留自己的内存边界。
- 程序静态资源随应用镜像同源发布;七牛只存用户上传的业务图片、票据和附件。
- 只有本地初始化、服务器维护、生产发布或数据库迁移任务需要读取 `docs/开发与部署SOP.md` 的对应章节;普通功能开发无需加载整份 SOP。

View File

@ -1,163 +0,0 @@
<?php
namespace App\Business;
use App\Models\Business\CourierAgreementSignature;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Throwable;
/** Read the signed snapshot directly: no duplicate contracts, attachment copies or archive backfill. */
class CourierContractPageData
{
public const REMINDER_DAYS = 30;
public const STATUSES = [
'due' => '即将到期',
'expired' => '约定期限已届满',
'active' => '有效期内',
'upcoming' => '未到开始日期',
'unknown' => '期限待核对',
];
public function page(bool $includeRows = true): array
{
$search = mb_substr(trim((string) request()->query('contract_search', '')), 0, 255);
$status = (string) request()->query('contract_status', '');
$status = array_key_exists($status, self::STATUSES) ? $status : '';
$from = $this->date(request()->query('contract_end_from'));
$to = $this->date(request()->query('contract_end_to'));
if ($from && $to && $from > $to) {
[$from, $to] = [$to, $from];
}
$filters = ['contract_search' => $search, 'contract_status' => $status,
'contract_end_from' => $from ?? '', 'contract_end_to' => $to ?? ''];
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$page = max((int) request()->query('page', 1), 1);
$options = ['path' => request()->url(), 'query' => request()->except('page')];
$data = [
'courierContractFilters' => $filters,
'courierContractRows' => new LengthAwarePaginator([], 0, $limit, $page, $options),
'courierContractCounts' => array_fill_keys(array_keys(self::STATUSES), 0),
'courierContractUnreadable' => 0,
'courierContractError' => null,
];
try {
if (! Schema::hasTable('business_courier_agreement_signatures')) {
$data['courierContractError'] = '协议签署数据表尚未建立,合同管理暂时无法读取。';
return $data;
}
$matched = [];
$today = Carbon::today(config('app.timezone'));
// Dates and partner types are encrypted. Stream bounded chunks instead of SQL JSON queries
// or persisting a second, potentially stale copy of the signed business snapshot.
$signatures = CourierAgreementSignature::query()->where('company_id', (int) company_id())
->where('document_type', 'cooperation-contract')->where('status', CourierAgreementSignature::STATUS_ARCHIVED)
->select(['id', 'company_id', 'business_no', 'courier_name', 'payload', 'document_type', 'status',
'signed_pdf_path', 'certificate_path', 'archived_at', 'created_by', 'created_at'])
->lazyById(250);
foreach ($signatures as $signature) {
$payload = $signature->archivedCourierContractPayload();
if ($payload === null) {
try {
$signature->payload;
} catch (Throwable) {
$data['courierContractUnreadable']++;
}
continue;
}
$row = $this->snapshot($signature, $payload, $today);
$data['courierContractCounts'][$row['status']]++;
if (! $includeRows || ($status !== '' && $status !== $row['status'])) {
continue;
}
if (($from && (! $row['end_date'] || $row['end_date'] < $from))
|| ($to && (! $row['end_date'] || $row['end_date'] > $to))) {
continue;
}
if ($search !== '' && ! str_contains(mb_strtolower(implode(' ', [
$row['courier_name'], $row['number'], $row['business_no'], $row['party_a'], $row['party_b'],
])), mb_strtolower($search))) {
continue;
}
$matched[] = $row;
}
if ($includeRows) {
$priority = ['due' => 0, 'expired' => 1, 'unknown' => 2, 'active' => 3, 'upcoming' => 4];
usort($matched, static fn (array $a, array $b): int =>
[$priority[$a['status']], $a['end_date'] ?? '9999-12-31', -$a['id']]
<=> [$priority[$b['status']], $b['end_date'] ?? '9999-12-31', -$b['id']]);
$total = count($matched);
$items = array_slice($matched, ($page - 1) * $limit, $limit);
$owners = user_model_class()::query()->whereIn('id', array_filter(array_column($items, 'created_by')))
->pluck('name', 'id');
foreach ($items as &$row) {
$row['creator'] = $owners[$row['created_by']] ?? '未记录';
$row['has_signed_file'] = $this->listedFileAvailable($row['signed_path']);
$row['has_certificate'] = $this->listedFileAvailable($row['certificate_path']);
unset($row['signed_path'], $row['certificate_path']);
}
unset($row);
$data['courierContractRows'] = new LengthAwarePaginator($items, $total, $limit, $page, $options);
}
} catch (Throwable $exception) {
report($exception);
$data['courierContractCounts'] = array_fill_keys(array_keys(self::STATUSES), 0);
$data['courierContractError'] = '归档合同暂时无法读取,请稍后重试。';
}
return $data;
}
private function snapshot(CourierAgreementSignature $signature, array $payload, Carbon $today): array
{
$start = $this->date($payload['cooperation_start_date'] ?? null);
$end = $this->date($payload['cooperation_end_date'] ?? null);
$days = $end ? (int) $today->diffInDays(Carbon::parse($end, config('app.timezone')), false) : null;
$status = match (true) {
! $start || ! $end || $start > $end => 'unknown',
$end < $today->toDateString() => 'expired',
$start > $today->toDateString() => 'upcoming',
$days <= self::REMINDER_DAYS => 'due',
default => 'active',
};
$person = ($payload['counterparty_signer_type'] ?? 'organization') === 'person';
return [
'id' => (int) $signature->id,
'courier_name' => (string) $signature->courier_name,
'business_no' => (string) $signature->business_no,
'number' => trim((string) ($payload['cooperation_contract_number'] ?? '')),
'party_a' => trim((string) ($payload['cooperation_party_a_name'] ?? '')),
'party_b' => trim((string) ($payload['cooperation_party_b_name'] ?? '')),
'legal_representative' => $person ? '不适用(个人签署)' : trim((string) ($payload['cooperation_party_b_legal_representative'] ?? '')),
'start_date' => $start, 'end_date' => $end, 'days' => $status === 'unknown' ? null : $days,
'status' => $status, 'created_by' => $signature->created_by,
'created_at' => $signature->created_at, 'archived_at' => $signature->archived_at,
'signed_path' => $signature->signed_pdf_path, 'certificate_path' => $signature->certificate_path,
];
}
private function date(mixed $value): ?string
{
if (! is_string($value) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return null;
}
[$year, $month, $day] = array_map('intval', explode('-', $value));
return checkdate($month, $day, $year) ? $value : null;
}
private function listedFileAvailable(?string $path): bool
{
if (blank($path)) {
return false;
}
// Do not issue up to 50 sequential object-storage HEAD requests for one list page.
// Downloads always verify the actual file; local files can be checked cheaply here.
return config('filesystems.disks.uploads.driver') !== 'local' || Storage::disk('uploads')->exists($path);
}
}

View File

@ -1,222 +0,0 @@
<?php
namespace App\Business;
use Illuminate\Database\DatabaseManager;
use InvalidArgumentException;
use RuntimeException;
/**
* 快递运费导入(.xlsx / .csv)。
*
* 源表头(按表头名匹配,列顺序无关):
* - 必需:来源 / 成品配件(也接受「成品运费」)/ 交易单号 / 提供成本运费;
* - 可选:快递单号 / 条形码 / 店铺名称(「财务系统运费整理.xlsx」起提供的列,老文件没有也能导)。
* 快递方给的文件常见几十万行,xlsx 走 XlsxSheetReader 流式读取,不整表进内存。
*/
class CourierFreightImporter
{
/** 源表头 → 数据库列 */
private const COLUMNS = [
'来源' => 'source',
'成品配件' => 'part',
'成品运费' => 'part',
'交易单号' => 'transaction_no',
'快递单号' => 'express_no',
'条形码' => 'barcode',
'店铺名称' => 'shop_name',
'提供成本运费' => 'freight_amount',
];
private const HEADERS = ['来源', '成品配件', '交易单号', '提供成本运费'];
/** 可选列:文件里有就导入,没有就留空(老文件兼容)。 */
private const OPTIONAL_HEADERS = ['快递单号', '条形码', '店铺名称'];
/** 表头别名:源文件的列名写法不统一(成品配件 / 成品运费;店铺名称 / 店铺)。 */
private const HEADER_ALIASES = [
'成品配件' => ['成品配件', '成品运费'],
'店铺名称' => ['店铺名称', '店铺'],
];
private const CHUNK = 2000;
public function __construct(
private readonly DatabaseManager $databases,
private readonly ?XlsxSheetReader $reader = null,
) {}
/**
* @return array{file:string,rows:int,inserted:int,skipped:int,amount:string,sources:array<string,int>,seconds:float}
*/
public function import(string $path, int $companyId = 1, bool $replace = false, string $source = 'courier-freight-import'): array
{
if (! is_file($path)) {
throw new InvalidArgumentException('找不到要导入的文件:' . $path);
}
$target = $this->databases->connection();
$startedAt = microtime(true);
$now = now();
if ($replace) {
$target->table('courier_freight_rows')->where('company_id', $companyId)->delete();
}
$inserted = 0;
$skipped = 0;
$amount = 0.0;
$sources = [];
$buffer = [];
$flush = function () use (&$buffer, &$inserted, $target): void {
foreach (array_chunk($buffer, self::CHUNK) as $chunk) {
$target->table('courier_freight_rows')->insert($chunk);
$inserted += count($chunk);
}
$buffer = [];
};
foreach ($this->lines($path) as $line) {
$sourceValue = $this->text($line['来源'] ?? null, 64);
$transactionNo = $this->text($line['交易单号'] ?? null, 191);
if ($sourceValue === null && $transactionNo === null) {
$skipped++;
continue;
}
$value = $this->number($line['提供成本运费'] ?? null);
$sources[$sourceValue ?? ''] = ($sources[$sourceValue ?? ''] ?? 0) + 1;
$amount += $value;
$buffer[] = [
'company_id' => $companyId,
'source' => $sourceValue ?? '',
'part' => $this->text($line['成品配件'] ?? null, 191),
'transaction_no' => $transactionNo ?? '',
'express_no' => $this->text($line['快递单号'] ?? null, 64),
'barcode' => $this->text($line['条形码'] ?? null, 191),
'shop_name' => $this->text($line['店铺名称'] ?? null, 191),
'freight_amount' => number_format($value, 4, '.', ''),
'created_at' => $now,
'updated_at' => $now,
];
if (count($buffer) >= self::CHUNK * 4) {
$flush();
}
}
$flush();
arsort($sources);
return [
'file' => basename($path),
'rows' => $inserted + $skipped,
'inserted' => $inserted,
'skipped' => $skipped,
'amount' => number_format($amount, 2, '.', ''),
'sources' => $sources,
'seconds' => round(microtime(true) - $startedAt, 1),
];
}
/** @return \Generator<int, array<string,string>> */
private function lines(string $path): \Generator
{
if (mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'csv') {
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException('无法读取文件:' . $path);
}
try {
$header = null;
while (($cells = fgetcsv($handle)) !== false) {
if ($header === null) {
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) ($cells[0] ?? ''));
$header = array_map(static fn ($value): string => trim((string) $value), $cells);
$this->assertHeader($header);
continue;
}
$line = [];
foreach ($header as $index => $name) {
$line[$this->canonicalName($name)] = (string) ($cells[$index] ?? '');
}
yield $line;
}
} finally {
fclose($handle);
}
return;
}
if (mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'xlsx') {
throw new InvalidArgumentException('只支持 xlsx、csv。');
}
$columns = array_map(
static fn (string $name): array|string => self::HEADER_ALIASES[$name] ?? $name,
self::HEADERS,
);
$optionalColumns = array_map(
static fn (string $name): array|string => self::HEADER_ALIASES[$name] ?? $name,
self::OPTIONAL_HEADERS,
);
yield from ($this->reader ?? app(XlsxSheetReader::class))->rows($path, $columns, $optionalColumns);
}
/** 把表头别名折回规范列名。 */
private function canonicalName(string $header): string
{
foreach (self::HEADER_ALIASES as $canonical => $names) {
if (in_array($header, $names, true)) {
return $canonical;
}
}
return $header;
}
/** @param list<string> $header */
private function assertHeader(array $header): void
{
foreach (self::HEADERS as $required) {
$names = self::HEADER_ALIASES[$required] ?? [$required];
if (array_intersect($names, $header) === []) {
throw new InvalidArgumentException('文件缺少列:' . $required);
}
}
}
private function text(mixed $value, int $limit): ?string
{
if ($value === null) {
return null;
}
$text = trim((string) (is_scalar($value) ? $value : ''));
return $text === '' ? null : mb_substr($text, 0, $limit);
}
private function number(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
if (is_numeric($value)) {
return (float) $value;
}
$text = str_replace([',', ' ', '¥', '¥'], '', (string) $value);
return is_numeric($text) ? (float) $text : 0.0;
}
}

View File

@ -1,98 +0,0 @@
<?php
namespace App\Business;
use App\Models\Business\CourierOnboarding;
use App\Support\CourierOnboardingService;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* 「快递入驻」列表页数据:状态统计、筛选和分页。
*/
class CourierOnboardingPageData
{
/** 列表页状态分组,键为页面筛选值。 */
public const STATUS_GROUPS = [
'pending' => ['label' => '待处理', 'statuses' => [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED, CourierOnboarding::STATUS_APPROVED]],
'filling' => ['label' => '待快递填写', 'statuses' => [CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_RETURNED]],
'activated' => ['label' => '已开通', 'statuses' => [CourierOnboarding::STATUS_ACTIVATED]],
'closed' => ['label' => '已关闭', 'statuses' => [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED]],
'all' => ['label' => '全部', 'statuses' => []],
];
public function __construct(private readonly CourierOnboardingService $service)
{
}
public function page(): array
{
$group = (string) request()->query('group', 'pending');
$group = array_key_exists($group, self::STATUS_GROUPS) ? $group : 'pending';
$search = trim((string) request()->query('search', ''));
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$data = [
'view' => 'business.pages.courier-payables.courier-onboarding',
'onboardingReady' => $this->service->ready(),
'onboardingGroup' => $group,
'onboardingSearch' => $search,
'onboardingGroups' => self::STATUS_GROUPS,
'onboardingCounts' => [],
'onboardingRows' => new LengthAwarePaginator([], 0, $limit),
];
if (! $data['onboardingReady']) {
return $data;
}
$counts = $this->service->statusCounts();
$data['onboardingCounts'] = collect(self::STATUS_GROUPS)
->map(static fn (array $config): int => $config['statuses'] === []
? array_sum($counts)
: array_sum(array_intersect_key($counts, array_flip($config['statuses']))))
->all();
$query = CourierOnboarding::query()->withCount('files');
$statuses = self::STATUS_GROUPS[$group]['statuses'];
if ($statuses !== []) {
$query->whereIn('status', $statuses);
}
if ($search !== '') {
$like = '%' . addcslashes($search, '%_\\') . '%';
$query->where(static function ($builder) use ($like): void {
foreach (['number', 'invite_label', 'company_full_name', 'network_name', 'courier_name', 'contact_name', 'contact_phone'] as $column) {
$builder->orWhere($column, 'like', $like);
}
});
}
$data['onboardingRows'] = $query->latest('updated_at')->latest('id')
->paginate($limit)
->withQueryString();
return $data;
}
/**
* 快递资料页「入驻快递」页签:已开通的入驻快递,作为新快递的主数据。
*/
public function activated(int $page, int $limit): LengthAwarePaginator
{
if (! $this->service->ready()) {
return new LengthAwarePaginator([], 0, $limit, $page);
}
$search = trim((string) request()->query('search', ''));
$query = CourierOnboarding::query()->where('status', CourierOnboarding::STATUS_ACTIVATED);
if ($search !== '') {
$like = '%' . addcslashes($search, '%_\\') . '%';
$query->where(static function ($builder) use ($like): void {
foreach (['courier_name', 'company_full_name', 'network_name', 'contact_name'] as $column) {
$builder->orWhere($column, 'like', $like);
}
});
}
return $query->latest('activated_at')->paginate($limit, ['*'], 'page', $page)->withQueryString();
}
}

View File

@ -1,314 +0,0 @@
<?php
namespace App\Business;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingFile;
use App\Support\CourierRequoteService;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Throwable;
/**
* 快递总览:工作平台(钉钉流程 → wp_express)和财务系统快递入驻按快递合成一行。
*
* 字段各有归属:快递代码、发货账号、ERP账户ID 归工作平台;主体、对公/对私账户、证照、报价、签署归财务系统。
* 两边先按入驻里关联的「工作平台快递代码」对,没关联的按系统快递名称对。
*/
final class CourierOverviewData
{
public const SOURCES = [
'erp_pending' => '待 ERP 开通',
'both' => '入驻 + 工作平台',
'onboarding' => '只有入驻',
'platform' => '只在工作平台(缺入驻资料)',
'mismatch' => '账户对不上',
];
private ?Collection $onboardingCache = null;
public function __construct(
private readonly CourierInformationMirrorRepository $mirror,
private readonly CourierRequoteService $requotes,
) {
}
/**
* @return array{rows:LengthAwarePaginator,counts:array<string,int>,platform_ready:bool}
*/
public function page(array $filters, int $page, int $perPage): array
{
$all = $this->rows();
$platformReady = $all['platform_ready'];
$rows = collect($all['rows']);
$counts = [
'all' => $rows->count(),
'both' => $rows->where('source', 'both')->count(),
'platform' => $rows->where('source', 'platform')->count(),
'onboarding' => $rows->where('source', 'onboarding')->count(),
'mismatch' => $rows->where('account_status', 'mismatch')->count(),
'erp_pending' => $rows->where('erp_status', 'pending')->count(),
];
$source = (string) ($filters['source'] ?? '');
if ($source === 'mismatch') {
$rows = $rows->where('account_status', 'mismatch');
} elseif ($source === 'erp_pending') {
$rows = $rows->where('erp_status', 'pending');
} elseif (array_key_exists($source, self::SOURCES)) {
$rows = $rows->where('source', $source);
}
$keyword = trim((string) ($filters['search'] ?? ''));
if ($keyword !== '') {
$rows = $rows->filter(static fn (array $row): bool => str_contains(
mb_strtolower($row['name'] . ' ' . ($row['expcode'] ?? '') . ' ' . ($row['onboarding']?->company_full_name ?? '')),
mb_strtolower($keyword),
));
}
$rows = $rows->values();
return [
'rows' => new LengthAwarePaginator(
$rows->forPage($page, $perPage)->values(),
$rows->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
),
'counts' => $counts,
'platform_ready' => $platformReady,
];
}
/**
* @return array{rows:list<array<string,mixed>>,platform_ready:bool}
*/
public function rows(): array
{
try {
$platform = $this->mirror->overviewSource();
} catch (Throwable $exception) {
report($exception);
$platform = null;
}
$onboardings = Schema::hasTable('business_courier_onboardings')
? CourierOnboarding::query()
->with('files')
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
->latest('id')
->get()
: collect();
$byCode = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->workplatform_expcode))
->keyBy(static fn (CourierOnboarding $o): string => (string) $o->workplatform_expcode);
$byName = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->courier_name))
->keyBy(static fn (CourierOnboarding $o): string => CourierOnboarding::normalizeName($o->courier_name));
$used = [];
$rows = [];
foreach ($platform['express'] ?? [] as $express) {
$code = (string) $express->expcode;
$onboarding = $byCode->get($code) ?? $byName->get(CourierOnboarding::normalizeName($express->expname));
if ($onboarding !== null && isset($used[$onboarding->id])) {
$onboarding = null;
}
if ($onboarding !== null) {
$used[$onboarding->id] = true;
}
$rows[] = $this->row($onboarding, $express, $platform['accounts'][$code] ?? []);
}
foreach ($onboardings as $onboarding) {
if (! isset($used[$onboarding->id])) {
$rows[] = $this->row($onboarding, null, []);
}
}
usort($rows, static fn (array $a, array $b): int => [$a['sort'], $a['name']] <=> [$b['sort'], $b['name']]);
return ['rows' => $rows, 'platform_ready' => $platform !== null];
}
/**
* @param list<object> $accounts
* @return array<string,mixed>
*/
private function row(?CourierOnboarding $onboarding, ?object $express, array $accounts): array
{
$source = $onboarding && $express ? 'both' : ($express ? 'platform' : 'onboarding');
$platformNumbers = collect($accounts)
->map(static fn ($account): string => preg_replace('/\D+/', '', (string) ($account->account ?? '')))
->filter()
->values();
$onboardingNumbers = collect([$onboarding?->bank_account_number, $onboarding?->private_account_number])
->map(static fn ($number): string => preg_replace('/\D+/', '', (string) $number))
->filter()
->values();
$accountStatus = match (true) {
$source !== 'both' => null,
$platformNumbers->isEmpty() => 'platform_missing',
$onboardingNumbers->isEmpty() => 'onboarding_missing',
$onboardingNumbers->diff($platformNumbers)->isEmpty() => 'match',
$onboardingNumbers->intersect($platformNumbers)->isNotEmpty() => 'partial',
default => 'mismatch',
};
$required = $onboarding ? CourierOnboardingFile::requiredKindsFor($onboarding->entity_type) : [];
$kinds = $onboarding ? $onboarding->files->pluck('kind')->unique()->all() : [];
$missingFiles = array_values(array_diff($required, $kinds));
$effective = $onboarding && filled($onboarding->courier_name) && Schema::hasTable('business_courier_quote_versions')
? $this->requotes->effectiveVersion((string) $onboarding->courier_name)
: null;
// ERP 开通:入驻开通后等钉钉「新建快递」流程回填快递代码;工作平台老快递视为已开通。
$erpStatus = match (true) {
$onboarding === null => 'opened',
$onboarding->status !== CourierOnboarding::STATUS_ACTIVATED => 'na',
$onboarding->erp_opened_at !== null || $express !== null => 'opened',
default => 'pending',
};
$erpAccountId = $onboarding?->erp_account_id
?: collect($accounts)->pluck('erpAccountId')->filter()->unique()->implode(' / ');
return [
'erp_status' => $erpStatus,
'erp_account_id' => $erpAccountId ?: null,
'erp_source' => $onboarding?->erpSourceLabel() ?: ($express ? '工作平台' : null),
'name' => (string) ($express->expname ?? $onboarding?->courier_name ?? $onboarding?->company_full_name ?? ''),
'expcode' => $express?->expcode ?? $onboarding?->workplatform_expcode,
'source' => $source,
'sort' => match (true) {
$erpStatus === 'pending' => 0,
$accountStatus === 'mismatch' => 1,
$source === 'onboarding' => 2,
$source === 'both' => 3,
default => 4,
},
'platform' => $express,
'platform_accounts' => $accounts,
'onboarding' => $onboarding,
'account_status' => $accountStatus,
'missing_files' => array_map(static fn (string $kind): string => CourierOnboardingFile::labelFor($kind, $onboarding?->entity_type), $missingFiles),
'effective_version' => $effective?->version_code,
'linked_by' => $onboarding && $express ? ((string) $onboarding->workplatform_expcode === (string) $express->expcode ? 'code' : 'name') : null,
];
}
/**
* 「快递资料」表格右侧接上的入驻信息:本页工作平台快递按快递代码 / 系统快递名称对上的入驻,
* 以及还没进工作平台(待 ERP 开通、或停用工作平台后新入驻)的快递,放在第一页最前面。
*
* @param list<object> $expressRows 本页工作平台快递
* @return array{matches:array<string,array<string,mixed>>,extra:list<array<string,mixed>>}
*/
public function expressAppend(array $expressRows, int $page, string $nameKeyword = '', string $codeFilter = ''): array
{
$onboardings = $this->onboardings();
if ($onboardings->isEmpty()) {
return ['matches' => [], 'extra' => []];
}
$byCode = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->workplatform_expcode))
->keyBy(static fn (CourierOnboarding $o): string => (string) $o->workplatform_expcode);
$byName = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->courier_name))
->keyBy(static fn (CourierOnboarding $o): string => CourierOnboarding::normalizeName($o->courier_name));
$matches = [];
foreach ($expressRows as $express) {
$onboarding = $byCode->get((string) ($express->expcode ?? '')) ?? $byName->get(CourierOnboarding::normalizeName($express->expname ?? ''));
if ($onboarding !== null) {
$matches[(string) ($express->Id ?? $express->expcode)] = $this->onboardingInfo($onboarding, true);
}
}
$extra = [];
if ($page === 1) {
try {
$platform = $this->mirror->overviewSource();
} catch (Throwable) {
$platform = null;
}
$codes = collect($platform['express'] ?? [])->pluck('expcode')->map(static fn ($code): string => (string) $code)->flip();
$names = collect($platform['express'] ?? [])->pluck('expname')->map(static fn ($name): string => CourierOnboarding::normalizeName($name))->flip();
foreach ($onboardings as $onboarding) {
$inPlatform = (filled($onboarding->workplatform_expcode) && $codes->has((string) $onboarding->workplatform_expcode))
|| (filled($onboarding->courier_name) && $names->has(CourierOnboarding::normalizeName($onboarding->courier_name)));
if ($inPlatform) {
continue;
}
if ($codeFilter !== '' && strcasecmp((string) $onboarding->workplatform_expcode, $codeFilter) !== 0) {
continue;
}
if ($nameKeyword !== '' && ! str_contains(mb_strtolower((string) $onboarding->courier_name . ' ' . $onboarding->company_full_name), mb_strtolower($nameKeyword))) {
continue;
}
$extra[] = $this->onboardingInfo($onboarding, false);
}
}
return ['matches' => $matches, 'extra' => $extra];
}
/** 快递资料里选中的一行对应的入驻(先按快递公司代码,再按系统快递名称)。 */
public function infoFor(?string $code, ?string $name): ?array
{
$onboardings = $this->onboardings();
$onboarding = (filled($code) ? $onboardings->first(static fn (CourierOnboarding $o): bool => strcasecmp((string) $o->workplatform_expcode, (string) $code) === 0) : null)
?? (filled($name) ? $onboardings->first(static fn (CourierOnboarding $o): bool => CourierOnboarding::normalizeName($o->courier_name) === CourierOnboarding::normalizeName($name)) : null);
return $onboarding !== null ? $this->onboardingInfo($onboarding, true) : null;
}
/** @return Collection<int,CourierOnboarding> */
private function onboardings(): Collection
{
return $this->onboardingCache ??= Schema::hasTable('business_courier_onboardings')
? CourierOnboarding::query()
->with('files')
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
->latest('id')
->get()
: collect();
}
/** @return array<string,mixed> */
private function onboardingInfo(CourierOnboarding $onboarding, bool $inPlatform): array
{
$required = CourierOnboardingFile::requiredKindsFor($onboarding->entity_type);
$missing = array_values(array_diff($required, $onboarding->files->pluck('kind')->unique()->all()));
$effective = filled($onboarding->courier_name) && Schema::hasTable('business_courier_quote_versions')
? $this->requotes->effectiveVersion((string) $onboarding->courier_name)
: null;
// 合同起止:协议签署里「合作合同」已签署的那份(最新一份)上填的合作期限。
$contract = app(\App\Support\CourierOnboardingService::class)->agreementSignatures($onboarding)
->first(static fn (\App\Models\Business\CourierAgreementSignature $signature): bool => $signature->document_type === 'cooperation-contract'
&& in_array($signature->status, [\App\Models\Business\CourierAgreementSignature::STATUS_COMPLETED, \App\Models\Business\CourierAgreementSignature::STATUS_ARCHIVED], true));
$contractPayload = $contract !== null && is_array($contract->payload) ? $contract->payload : [];
return [
'contract_start' => $contractPayload['cooperation_start_date'] ?? null,
'contract_end' => $contractPayload['cooperation_end_date'] ?? null,
'onboarding' => $onboarding,
'erp_status' => match (true) {
$onboarding->status !== CourierOnboarding::STATUS_ACTIVATED => 'na',
$inPlatform || $onboarding->erp_opened_at !== null => 'opened',
default => 'pending',
},
'checks' => $onboarding->accountChecks(),
'missing_files' => array_map(static fn (string $kind): string => CourierOnboardingFile::labelFor($kind, $onboarding->entity_type), $missing),
'effective_version' => $effective?->version_code,
];
}
public static function mask(?string $number): string
{
$digits = preg_replace('/\s+/', '', (string) $number);
return $digits === '' ? '' : '尾号 ' . mb_substr($digits, -4);
}
}

View File

@ -1,139 +0,0 @@
<?php
namespace App\Business;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Throwable;
/**
* 快递应付的两个汇总页 → 「交易单号 ⇒ 金额类取值」映射。
*
* 这些数据都是财务在「应付款项 › 快递应付 › 运费差 / 其他附加费汇总 / 退件费」页面上传的汇总快照
* (JSON 文件,不是数据库表),快递运费页面按交易单号把对应金额带出来展示。
* 快照重新导入后文件时间会变,缓存键跟着变,自动失效。
*/
class CourierPayableSummaryMap
{
/** 运费差:差异(结算运费-提供成本运费)。 */
public const PAGE_FREIGHT_DIFFERENCE = 'freight-difference';
/** 其他附加费汇总:金额。 */
public const PAGE_OTHER_SURCHARGE = 'other-surcharge-summary';
/** 退件费:结算金额(单号列叫「交易订单」)。 */
public const PAGE_RETURN_FEE = 'return-fee';
/**
* 汇总页 → [单号列的关键字候选, 取值列的关键字候选]。
*
* 各页表头写法不统一(「交易单号」/「交易订单」、「金额」/「结算金额」),
* 所以按候选顺序找:先全等匹配,再退化成包含匹配。
*/
private const PAGES = [
self::PAGE_FREIGHT_DIFFERENCE => [['交易单号', '交易订单'], ['差异', '金额']],
self::PAGE_OTHER_SURCHARGE => [['交易单号', '交易订单'], ['金额']],
self::PAGE_RETURN_FEE => [['交易订单', '交易单号'], ['结算金额', '金额']],
];
private const CACHE_MINUTES = 10;
public function __construct(private readonly CourierPayableSummaryStore $store) {}
/**
* @param string $page self::PAGE_* 之一
* @return array<string,string> 交易单号 => 金额(保留源文件里的原值字符串)
*/
public function map(int $companyId, string $page): array
{
if (! isset(self::PAGES[$page])) {
return [];
}
$disk = Storage::disk('uploads');
$path = $this->store->path($companyId, $page);
if (! $disk->exists($path)) {
return [];
}
// 文件时间戳进缓存键:财务重新导入运费差后不用等缓存过期。
$key = sprintf('courier-payable-summary-map:v1:%s:%d:%d', $page, $companyId, (int) $disk->lastModified($path));
return Cache::remember($key, now()->addMinutes(self::CACHE_MINUTES), function () use ($companyId, $page): array {
try {
return $this->build($companyId, $page);
} catch (Throwable $exception) {
report($exception);
return [];
}
});
}
/** @return array<string,string> */
private function build(int $companyId, string $page): array
{
$snapshot = $this->store->snapshot($companyId, $page);
if ($snapshot === null) {
return [];
}
[$orderHeaders, $valueHeaders] = self::PAGES[$page];
$headers = array_map(static fn ($value): string => trim((string) $value), (array) $snapshot['headers']);
$orderColumn = $this->columnIndex($headers, $orderHeaders);
$valueColumn = $this->columnIndex($headers, $valueHeaders);
if ($orderColumn === null || $valueColumn === null) {
return [];
}
$map = [];
foreach ((array) $snapshot['rows'] as $row) {
$orderNos = trim((string) ($row[$orderColumn] ?? ''));
if ($orderNos === '') {
continue;
}
$value = trim((string) ($row[$valueColumn] ?? ''));
if ($value === '') {
continue;
}
// 源表一个格子可能放多个交易单号(逗号分隔),逐个挂到同一个金额上。
foreach (explode(',', $orderNos) as $orderNo) {
$orderNo = trim($orderNo);
if ($orderNo !== '' && ! isset($map[$orderNo])) {
$map[$orderNo] = $value;
}
}
}
return $map;
}
/**
* 按表头名定位:候选顺序优先,每个候选先找完全相等的表头,再退化成包含匹配
* (例如「差异(结算运费-提供成本运费)」只能靠包含匹配)。
*
* @param list<string> $needles
*/
private function columnIndex(array $headers, array $needles): ?int
{
foreach ($needles as $needle) {
foreach ($headers as $index => $header) {
if ($header === $needle) {
return (int) $index;
}
}
}
foreach ($needles as $needle) {
foreach ($headers as $index => $header) {
if ($header !== '' && mb_strpos($header, $needle) !== false) {
return (int) $index;
}
}
}
return null;
}
}

View File

@ -1,382 +0,0 @@
<?php
namespace App\Business;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierPaymentAccount;
use App\Models\Business\CourierProfile;
use App\Models\Business\CourierProfileEvent;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Throwable;
/**
* 快递资料在财务系统这一侧的数据:编辑覆盖值、打款信息(入驻对公/对私 + 新增/变更记录)、操作日志。
* 工作平台镜像只读,这里的数据都存在财务系统自己的表里。
*/
final class CourierProfileData
{
/** @return array<string,array<string,mixed>> 快递代码 => 覆盖值 */
public function overrides(array $codes): array
{
$codes = array_values(array_unique(array_filter(array_map('strval', $codes), 'filled')));
if ($codes === [] || ! Schema::hasTable('business_courier_profiles')) {
return [];
}
return CourierProfile::query()->whereIn('expcode', $codes)->get()
->mapWithKeys(static fn (CourierProfile $profile): array => [(string) $profile->expcode => (array) $profile->overrides])
->all();
}
/** 把覆盖值盖到快递资料行上,`_overridden` 记下改过的字段。 */
public function apply(object $row, array $overrides): object
{
$row = clone $row;
$changed = [];
foreach ($overrides as $field => $value) {
if (array_key_exists($field, CourierProfile::EDITABLE)) {
$row->{$field} = $value;
$changed[] = $field;
}
}
$row->_overridden = $changed;
return $row;
}
/** 财务系统入驻(还没有工作平台记录)的快递,按快递资料的列铺成一行。 */
public function onboardingRow(array $info): object
{
/** @var CourierOnboarding $onboarding */
$onboarding = $info['onboarding'];
return (object) [
'Id' => null,
'expcode' => $onboarding->workplatform_expcode,
'expname' => $onboarding->courier_name,
'paymethod' => $onboarding->settlement_cycle,
'status' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? '1' : '0',
'kuaiyunsgn' => $onboarding->service_type === '快运' ? '1' : '0',
'tradebtime' => $info['contract_start'] ?? null,
'tradeetime' => $info['contract_end'] ?? null,
'linker' => $onboarding->contact_name,
'linkmobile' => $onboarding->contact_phone,
'dzqzjbr' => $onboarding->esign_account,
'undefined3' => $onboarding->deduction_method,
'modified' => $onboarding->updated_at?->toDateTimeString(),
'_onboarding_only' => true,
'_info' => $info,
];
}
/** 按快递公司代码取当前显示的一行主信息(工作平台镜像优先,没有则用财务系统入驻),已盖上编辑覆盖值。 */
public function currentRow(string $code): ?object
{
$mirror = app(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class);
$overview = app(CourierOverviewData::class);
$row = null;
try {
if ($mirror->available() && $mirror->ready()) {
$found = $mirror->paginate(array_merge(
array_fill_keys(['expname', 'undefined1', 'undefined2', 'undefined3', 'tradebtime_from', 'tradebtime_to', 'status'], ''),
['expcode' => $code],
), 1, 1)['data'][0] ?? null;
if ($found !== null) {
$row = clone $found;
$row->_info = $overview->infoFor($code, $found->expname ?? null);
}
}
} catch (Throwable $exception) {
report($exception);
}
if ($row === null && ($info = $overview->infoFor($code, null)) !== null) {
$row = $this->onboardingRow($info);
}
return $row !== null ? $this->apply($row, $this->overrides([$code])[$code] ?? []) : null;
}
/**
* 协议签署页预填:打款信息新增 → 收款账户证明;变更 → 收款账户变更。
*
* @return array<string,mixed>|null
*/
public static function agreementPrefill(mixed $id): ?array
{
if (! is_numeric($id) || ! Schema::hasTable('business_courier_payment_accounts')) {
return null;
}
$record = CourierPaymentAccount::query()->find((int) $id);
if ($record === null) {
return null;
}
$onboarding = app(CourierOverviewData::class)->infoFor($record->expcode, $record->courier_name)['onboarding'] ?? null;
$fields = $record->action === CourierPaymentAccount::ACTION_CHANGE
? [
'account_change_date' => $record->change_date?->toDateString(),
'old_account_name' => $record->old_account_name,
'old_account_number' => $record->old_account_number,
'old_account_stop_date' => $record->old_stop_date?->toDateString(),
'new_account_effective_date' => $record->effective_date?->toDateString(),
'new_account_name' => $record->account_name,
'new_bank_name' => $record->bank_name,
'new_bank_account' => $record->account_number,
]
: [
'account_proof_effective_date' => $record->effective_date?->toDateString(),
'account_proof_account_name' => $record->account_name,
'account_proof_bank_name' => $record->bank_name,
'account_proof_bank_account' => $record->account_number,
];
if ($onboarding !== null && filled($onboarding->company_full_name)) {
$fields['counterparty_organization_name'] = $onboarding->company_full_name;
}
return [
'id' => $record->id,
'action' => $record->actionLabel(),
'courier_name' => $record->courier_name,
'expcode' => $record->expcode,
'document_type' => $record->documentType(),
'signer_type' => $onboarding !== null && $onboarding->entity_type === CourierOnboarding::ENTITY_PERSON ? 'person' : 'organization',
'esign_account' => $onboarding?->esign_account,
'fields' => array_filter($fields, static fn ($value): bool => filled($value)),
];
}
/** 快递资料右侧接的入驻字段(我方填写;扣款方式对齐到左边「扣款方式」列,不重复显示)。 */
public const APPENDED_PROFILE_FIELDS = [
'courier_category', 'cooperation_mode', 'courier_attribute', 'waybill_type', 'shipping_mode',
'need_logistics_check', 'workplatform_account', 'courier_email', 'network_code', 'network_region',
];
/** 工作平台的行:扣款方式没填时用入驻里我方填的。 */
public function fillFromOnboarding(object $row): object
{
$onboarding = $row->_info['onboarding'] ?? null;
if ($onboarding !== null && blank($row->undefined3 ?? null) && filled($onboarding->deduction_method)) {
$row = clone $row;
$row->undefined3 = $onboarding->deduction_method;
}
return $row;
}
/** 入驻里填的网点,按工作平台网点表的列铺成一行(排在网点明细最前面)。 */
public function onboardingNetwork(CourierOnboarding $onboarding): ?object
{
if (blank($onboarding->network_code) && blank($onboarding->network_name) && blank($onboarding->network_region)) {
return null;
}
return (object) [
'id' => '入驻 ' . $onboarding->number,
'expcode' => $onboarding->workplatform_expcode,
'netcode' => $onboarding->network_code,
'netname' => $onboarding->network_name,
'address' => $onboarding->network_region,
'linker' => $onboarding->contact_name,
'linkermobile' => $onboarding->contact_phone,
'status' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? '1' : '0',
'created' => $onboarding->created_at?->toDateTimeString(),
];
}
/** 保存编辑:只记和当前显示值不同的字段,并写一条操作日志。 */
public function update(string $expcode, ?string $courierName, object $current, array $values, int $userId): int
{
$profile = CourierProfile::query()->firstOrNew(['company_id' => company_id(), 'expcode' => $expcode]);
$overrides = (array) ($profile->overrides ?? []);
$changes = [];
foreach (CourierProfile::EDITABLE as $field => $label) {
if (! array_key_exists($field, $values)) {
continue;
}
$new = $this->normalize($field, $values[$field]);
$old = $this->normalize($field, $current->{$field} ?? null);
if ($new === $old) {
continue;
}
$overrides[$field] = $new;
$changes[$field] = ['label' => $label, 'old' => $old, 'new' => $new];
}
if ($changes === []) {
return 0;
}
$profile->fill([
'courier_name' => $courierName ?: $profile->courier_name,
'overrides' => $overrides,
'updated_by' => $userId,
]);
$profile->created_by ??= $userId;
$profile->save();
$this->log($expcode, 'edit', '修改 ' . implode('、', array_column($changes, 'label')), $changes, $userId);
return count($changes);
}
public function log(string $expcode, string $action, string $summary, ?array $changes, int $userId): void
{
CourierProfileEvent::query()->create([
'company_id' => company_id(),
'expcode' => $expcode,
'action' => $action,
'summary' => mb_substr($summary, 0, 500),
'changes' => $changes,
'created_by' => $userId,
]);
}
/** @return Collection<int,CourierProfileEvent> */
public function events(?string $expcode = null, int $limit = 100): Collection
{
if (! Schema::hasTable('business_courier_profile_events')) {
return collect();
}
return CourierProfileEvent::query()
->with('creator')
->when(filled($expcode), static fn ($query) => $query->where('expcode', $expcode))
->latest('id')
->limit($limit)
->get();
}
/**
* 财务系统这一侧的打款信息(完整显示):入驻时填的对公/对私账户 + 打款信息新增/变更记录。
*
* @return list<array<string,mixed>>
*/
public function financeAccounts(?string $expcode = null, ?CourierOnboarding $onboarding = null): array
{
$rows = [];
if (Schema::hasTable('business_courier_payment_accounts')) {
$records = CourierPaymentAccount::query()
->when(filled($expcode), static fn ($query) => $query->where('expcode', $expcode))
->latest('id')
->get();
foreach ($records as $record) {
$status = $this->paymentStatus($record);
$rows[] = [
'source' => $record->actionLabel(),
'expcode' => $record->expcode,
'courier_name' => $record->courier_name,
'type' => $record->account_type,
'name' => $record->account_name,
'bank' => $record->bank_name,
'number' => $record->account_number,
'effective_date' => $record->effective_date?->toDateString(),
'old' => $record->action === CourierPaymentAccount::ACTION_CHANGE
? trim($record->old_account_name . ' ' . $record->old_account_number) . ($record->old_stop_date ? '(' . $record->old_stop_date->toDateString() . ' 停用)' : '')
: null,
'status' => $status['label'],
'tone' => $status['tone'],
'erp_account_id' => null,
'created_at' => $record->created_at?->toDateTimeString(),
'record' => $record,
];
}
}
$onboardings = $onboarding !== null
? collect([$onboarding])
: (Schema::hasTable('business_courier_onboardings')
? CourierOnboarding::query()
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
->when(filled($expcode), static fn ($query) => $query->where('workplatform_expcode', $expcode))
->latest('id')
->get()
: collect());
foreach ($onboardings as $item) {
$checks = $item->accountChecks();
foreach ([
['对公', $item->bank_account_name, $item->bank_name, $item->bank_account_number, $checks['corporate'] ?? null],
['对私', $item->private_account_name, $item->private_bank_name, $item->private_account_number, $checks['private'] ?? null],
] as [$type, $name, $bank, $number, $check]) {
if (! filled($name) && ! filled($number)) {
continue;
}
$rows[] = [
'source' => '快递入驻 ' . $item->number,
'expcode' => $item->workplatform_expcode,
'courier_name' => $item->courier_name,
'type' => $type,
'name' => $name,
'bank' => $bank,
'number' => $number,
'effective_date' => $item->activated_at?->toDateString(),
'old' => null,
'status' => $check === false ? ($type === '对公' ? '户名与营业执照主体不一致' : '收款人与法定代表人不一致') : $item->statusLabel(),
'tone' => $check === false ? 'is-bad' : ($item->status === CourierOnboarding::STATUS_ACTIVATED ? 'is-ok' : 'is-warn'),
'erp_account_id' => $item->erp_account_id,
'created_at' => $item->created_at?->toDateTimeString(),
'onboarding' => $item,
];
}
}
return $rows;
}
/**
* 打款信息新增/变更的状态:按协议签署里同一快递、同类文件、同一新账号的签署任务推导。
*
* @return array{label:string,tone:string,signature:?CourierAgreementSignature}
*/
public function paymentStatus(CourierPaymentAccount $record): array
{
$signature = null;
if (Schema::hasTable('business_courier_agreement_signatures') && filled($record->courier_name)) {
$field = $record->action === CourierPaymentAccount::ACTION_CHANGE ? 'new_bank_account' : 'account_proof_bank_account';
$number = $this->digits($record->account_number);
$signature = CourierAgreementSignature::query()
->where('courier_name', $record->courier_name)
->where('document_type', $record->documentType())
->where('created_at', '>=', $record->created_at)
->latest('id')
->get()
->first(function (CourierAgreementSignature $signature) use ($field, $number): bool {
try {
return $this->digits((string) data_get($signature->payload, $field)) === $number;
} catch (Throwable) {
return false;
}
});
}
return match (true) {
$signature === null => ['label' => '待推送协议', 'tone' => 'is-warn', 'signature' => null],
in_array($signature->status, [CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED], true) => ['label' => '已生效', 'tone' => 'is-ok', 'signature' => $signature],
in_array($signature->status, [CourierAgreementSignature::STATUS_REVOKED, CourierAgreementSignature::STATUS_EXPIRED, CourierAgreementSignature::STATUS_REJECTED, CourierAgreementSignature::STATUS_FAILED], true) => ['label' => '签署未完成,需重新推送', 'tone' => 'is-bad', 'signature' => $signature],
default => ['label' => '签署中', 'tone' => 'is-warn', 'signature' => $signature],
};
}
private function digits(?string $value): string
{
return (string) preg_replace('/[\s\-]+/u', '', (string) $value);
}
private function normalize(string $field, mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string) $value);
if ($value === '' || $value === '-') {
return null;
}
if (in_array($field, CourierProfile::BOOLEAN_FIELDS, true)) {
return in_array(strtolower($value), ['1', 'true', 'yes', 'y', '是'], true) ? '1' : '0';
}
if (in_array($field, CourierProfile::DATE_FIELDS, true)) {
return substr($value, 0, 10);
}
return $value;
}
}

View File

@ -1,237 +0,0 @@
<?php
namespace App\Business;
use App\Models\Business\InventoryWarehouseNameMapping;
use DateTimeImmutable;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
class InventoryInboundDetailImporter
{
private const TABLE = 'inventory_inbound_detail_rows';
/**
* Replace the imported inbound detail snapshot only after the workbook has
* been opened successfully. The delete and inserts share one transaction,
* so a malformed file cannot leave the page with a partially imported table.
*
* @return array{imported:int,mapped:int,skipped:int,total:int}
*/
public function import(string $file, string $forcedMonth = ''): array
{
$path = realpath($file);
if ($path === false || ! is_file($path) || ! is_readable($path)) {
throw new RuntimeException('入库明细文件不存在或不可读取。');
}
$forcedMonth = $this->normalizeForcedMonth($forcedMonth);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$now = now();
$maps = $this->shortNameMap();
$reader = IOFactory::createReaderForFile($path);
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($path);
$sheet = $spreadsheet->getSheet(0);
try {
return DB::connection($connection)->transaction(function () use (
$connection,
$sheet,
$maps,
$forcedMonth,
$now,
): array {
DB::connection($connection)->table(self::TABLE)->delete();
$batch = [];
$imported = 0;
$mapped = 0;
$skipped = 0;
foreach ($sheet->getRowIterator(2) as $row) {
$rowNumber = $row->getRowIndex();
$cells = [];
foreach ($row->getCellIterator('A', 'U') as $cell) {
$cells[] = $cell->getFormattedValue();
}
$warehouse = trim((string) ($cells[14] ?? ''));
$createdRaw = trim((string) ($cells[16] ?? ''));
if ($warehouse === '' && $createdRaw === '') {
$skipped++;
continue;
}
$month = $forcedMonth !== '' ? $forcedMonth : $this->monthOf($createdRaw);
if ($month === '') {
$skipped++;
continue;
}
// 代发仓按仓储方匹配;其它仓库按仓库名称做唯一匹配。
$shortName = '';
if ($warehouse !== '') {
$shortName = str_contains($warehouse, '代发仓')
? ($maps['provider'][$warehouse] ?? $maps['warehouse'][$warehouse] ?? '')
: ($maps['warehouse'][$warehouse] ?? '');
}
if ($shortName !== '') {
$mapped++;
}
$batch[] = [
'report_month' => $month,
'row_key' => sha1(substr($month, 0, 7) . '|' . $rowNumber),
'source_no' => $this->text($cells[0] ?? ''),
'document_no' => $this->text($cells[1] ?? ''),
'created_serial' => $this->text($cells[2] ?? ''),
'supplier' => $this->text($cells[3] ?? ''),
'item_no' => $this->text($cells[4] ?? ''),
'barcode' => $this->text($cells[5] ?? ''),
'spec_name' => $this->text($cells[6] ?? ''),
'quantity' => $this->number($cells[7] ?? ''),
'unit_price' => $this->number($cells[8] ?? ''),
'amount' => $this->number($cells[9] ?? ''),
'estimated_unit_price' => $this->number($cells[10] ?? ''),
'estimated_amount' => $this->number($cells[11] ?? ''),
'supplier_barcode' => $this->text($cells[12] ?? ''),
'remark' => $this->text($cells[13] ?? ''),
'warehouse' => $warehouse !== '' ? $warehouse : null,
'warehouse_short_name' => $shortName !== '' ? $shortName : null,
'created_at_raw' => $this->moment($createdRaw),
'express_name' => $this->text($cells[17] ?? ''),
'express_no' => $this->text($cells[18] ?? ''),
'not_sent' => $this->text($cells[19] ?? ''),
'owner_name' => $this->text($cells[20] ?? ''),
'imported_at' => $now,
];
$imported++;
if (count($batch) >= 1000) {
DB::connection($connection)->table(self::TABLE)->insertOrIgnore($batch);
$batch = [];
}
}
if ($batch !== []) {
DB::connection($connection)->table(self::TABLE)->insertOrIgnore($batch);
}
if ($imported === 0) {
throw new RuntimeException('入库明细文件中没有可导入的数据。');
}
return [
'imported' => $imported,
'mapped' => $mapped,
'skipped' => $skipped,
'total' => DB::connection($connection)->table(self::TABLE)->count(),
];
});
} finally {
$spreadsheet->disconnectWorksheets();
}
}
private function normalizeForcedMonth(string $month): string
{
$month = trim($month);
if ($month === '') {
return '';
}
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
return $month . '-01';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $month) === 1) {
return $month;
}
throw new RuntimeException('指定月份格式应为 YYYY-MM。');
}
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
private function shortNameMap(): array
{
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
$grouped = [];
foreach ($rows as $row) {
$warehouse = trim((string) $row->warehouse);
if ($warehouse === '') {
continue;
}
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
}
$byWarehouse = [];
foreach ($grouped as $warehouse => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byWarehouse[$warehouse] = $unique[0];
}
}
$byProvider = [];
foreach ($rows as $row) {
$provider = trim((string) $row->warehouse_provider);
if ($provider !== '') {
$byProvider[$provider][] = trim((string) $row->warehouse_short_name);
}
}
foreach ($byProvider as $provider => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byProvider[$provider] = $unique[0];
}
}
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
}
private function monthOf(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
$parsed = DateTimeImmutable::createFromFormat($format, $value);
if ($parsed instanceof DateTimeImmutable) {
return $parsed->format('Y-m') . '-01';
}
}
$timestamp = strtotime($value);
return $timestamp === false ? '' : date('Y-m-01', $timestamp);
}
private function moment(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
private function text(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function number(mixed $value): ?float
{
$text = str_replace(',', '', trim((string) $value));
if ($text === '' || ! is_numeric($text)) {
return null;
}
return (float) $text;
}
}

View File

@ -7,14 +7,10 @@ use InvalidArgumentException;
use RuntimeException;
/**
* 聚水潭订单标签明细导入(拆标签写入 jst_order_labels)。
* 聚水潭订单标签明细导入(从共享镜像 ops_jst_orders 拆标签写入 jst_order_labels)。
*
* 一个标签一行:一单有 N 个标签就写 N 行;订单不去重、标签不汇总。
* 无标签的订单保留一行,标签记为「(无标签)」,方便与订单总数核对。
*
* 两个数据源:
* - 共享镜像 ops_jst_orders(常规,覆盖镜像有数据的区间);
* - 聚水潭「导出订单」Excel(镜像没有的历史区间,例如 2026-03,只能靠财务导出补)。
*/
class JstOrderLabelImporter
{
@ -24,14 +20,7 @@ class JstOrderLabelImporter
private const INSERT_CHUNK = 2000;
/** 聚水潭导出文件里用到的表头。 */
private const EXPORT_COLUMNS = ['线上订单号', '店铺名称', '下单时间', '标签'];
public function __construct(
private readonly DatabaseManager $databases,
private readonly ?XlsxSheetReader $exportReader = null,
private readonly ?JstOrderLabelItemCodes $itemCodes = null,
) {}
public function __construct(private readonly DatabaseManager $databases) {}
/**
* @return array{from:string,to:string,orders:int,rows:int,no_label_orders:int,labels:int,seconds:float}
@ -54,8 +43,6 @@ class JstOrderLabelImporter
$target = $this->databases->connection();
$startedAt = microtime(true);
$itemCodes = $this->itemCodes ?? new JstOrderLabelItemCodes();
$hasItemTable = $mirror->getSchemaBuilder()->hasTable('ops_jst_order_item');
if ($replace) {
$target->table('jst_order_labels')
@ -63,7 +50,6 @@ class JstOrderLabelImporter
->where('ordered_at', '>=', $start)
->where('ordered_at', '<', $end)
->delete();
app(PlantingOrderLabelLookup::class)->forget($companyId);
}
$now = now();
@ -72,10 +58,9 @@ class JstOrderLabelImporter
$noLabelOrders = 0;
$buffer = [];
$flush = function () use (&$buffer, &$rows, $target, $companyId): void {
$flush = function () use (&$buffer, &$rows, $target): void {
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
$target->table('jst_order_labels')->insert($chunk);
app(PlantingOrderLabelLookup::class)->forget($companyId);
$rows += count($chunk);
}
$buffer = [];
@ -85,12 +70,7 @@ class JstOrderLabelImporter
->where('order_date', '>=', $start)
->where('order_date', '<', $end)
->orderBy('Id')
->chunkById(self::READ_CHUNK, function ($records) use (&$buffer, &$orders, &$noLabelOrders, $flush, $companyId, $source, $now, $itemCodes, $hasItemTable, $mirror): void {
// 货号:同批订单的商品编码(镜像商品明细,按 o_id 取),导入时一并写入
$orderCodes = $hasItemTable
? $itemCodes->forOrderIds($mirror, 'ops_jst_order_item', $records->pluck('o_id')->all())
: [];
->chunkById(self::READ_CHUNK, function ($records) use (&$buffer, &$orders, &$noLabelOrders, $flush, $companyId, $source, $now): void {
foreach ($records as $record) {
$orders++;
$orderedAt = $record->order_date === null ? null : substr((string) $record->order_date, 0, 19);
@ -104,8 +84,6 @@ class JstOrderLabelImporter
$noLabelOrders++;
}
$itemCode = $orderCodes[(int) $record->o_id] ?? null;
foreach ($labels as $label) {
$buffer[] = [
'company_id' => $companyId,
@ -113,7 +91,6 @@ class JstOrderLabelImporter
'shop_name' => mb_substr((string) ($record->shop_name ?? ''), 0, 191),
'ordered_at' => $orderedAt,
'label' => $label,
'item_code' => $itemCode,
'created_from' => $source,
'created_at' => $now,
'updated_at' => $now,
@ -150,192 +127,6 @@ class JstOrderLabelImporter
];
}
/**
* 从聚水潭「导出订单」Excel 导入。
*
* 导出文件是一单多行(商品明细),且同一单里可能只有部分明细行带标签:
* 第一遍先挑出真正有标签的订单号,第二遍再按「线上订单号 + 标签」去重拆行写入,
* 只对整单没标签的订单写「(无标签)」。同时兼容只勾 4 列导出的极简表头。
*
* 传 $from/$to 时会顺带按窗口过滤;带 $replace 时必须给窗口,
* 以便在写入前先清掉该区间,避免重复导入翻倍。
*
* @return array{file:string,company_id:int,from:?string,to:?string,orders:int,rows:int,no_label_orders:int,skipped:int,labels:int,seconds:float}
*/
public function importFromFile(
string $path,
?string $from = null,
?string $to = null,
int $companyId = 1,
bool $replace = false,
string $source = 'jst-order-label-file-import',
): array {
$from = $from === null || trim($from) === '' ? null : $this->normalizeDate(trim($from), '起始日期');
$to = $to === null || trim($to) === '' ? null : $this->normalizeDate(trim($to), '结束日期');
if ($from !== null && $to !== null && $from > $to) {
throw new InvalidArgumentException('起始日期不能晚于结束日期。');
}
if ($replace && ($from === null || $to === null)) {
throw new InvalidArgumentException('按文件导入并使用 --replace 时,必须同时给出 --from 与 --to,才能确定要清空的区间。');
}
$start = $from === null ? null : $from . ' 00:00:00';
$end = $to === null ? null : date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
$target = $this->databases->connection();
$startedAt = microtime(true);
// 清空区间推迟到第一次成功读到数据行之后:万一文件打不开或表头不对,
// 不能先把线上已有行删掉又导入失败。
$clearWindow = function () use ($target, $companyId, $start, $end): void {
$target->table('jst_order_labels')
->where('company_id', $companyId)
->where('ordered_at', '>=', $start)
->where('ordered_at', '<', $end)
->delete();
app(PlantingOrderLabelLookup::class)->forget($companyId);
};
$cleared = ! $replace;
$now = now();
$orders = 0;
$rows = 0;
$noLabelOrders = 0;
$skipped = 0;
$seen = [];
$orderSeen = [];
$buffer = [];
$flush = function () use (&$buffer, &$rows, $target, $companyId): void {
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
$target->table('jst_order_labels')->insert($chunk);
app(PlantingOrderLabelLookup::class)->forget($companyId);
$rows += count($chunk);
}
$buffer = [];
};
$reader = $this->exportReader ?? app(XlsxSheetReader::class);
// 第一遍:挑出「有标签」的订单号。聚水潭导出是一单多行,同一单常常只有部分
// 明细行带标签;不按订单判断就会给本来有标签的订单多写一行「(无标签)」。
$labeledOrders = [];
$anyRow = false;
foreach ($reader->rows($path, self::EXPORT_COLUMNS) as $line) {
$anyRow = true;
if ($this->splitLabels((string) ($line['标签'] ?? '')) === []) {
continue;
}
$orderNo = trim((string) ($line['线上订单号'] ?? ''));
if ($orderNo !== '') {
$labeledOrders[$orderNo] = true;
}
}
if (! $anyRow) {
throw new RuntimeException('订单导出文件里没有数据行:' . basename($path));
}
if (! $cleared) {
$clearWindow();
$cleared = true;
}
// 第二遍:按「线上订单号 + 标签」去重后写入。
foreach ($reader->rows($path, self::EXPORT_COLUMNS) as $line) {
$orderNo = trim((string) ($line['线上订单号'] ?? ''));
$orderedAt = $this->normalizeOrderedAt((string) ($line['下单时间'] ?? ''));
if ($orderNo === '' || $orderedAt === null) {
$skipped++;
continue;
}
if ($start !== null && ($orderedAt < $start || $orderedAt >= $end)) {
$skipped++;
continue;
}
$labels = $this->splitLabels((string) ($line['标签'] ?? ''));
$withoutLabel = $labels === [];
if ($withoutLabel) {
if (isset($labeledOrders[$orderNo])) {
continue;
}
$labels = [self::NO_LABEL];
}
if (! isset($orderSeen[$orderNo])) {
$orderSeen[$orderNo] = true;
$orders++;
}
$shopName = mb_substr(trim((string) ($line['店铺名称'] ?? '')), 0, 191);
foreach ($labels as $label) {
$fingerprint = hash('xxh3', $orderNo . "\0" . $label);
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
if ($withoutLabel) {
$noLabelOrders++;
}
$buffer[] = [
'company_id' => $companyId,
'order_no' => mb_substr($orderNo, 0, 100),
'shop_name' => $shopName,
'ordered_at' => $orderedAt,
'label' => $label,
'created_from' => $source,
'created_at' => $now,
'updated_at' => $now,
];
}
if (count($buffer) >= self::INSERT_CHUNK * 2) {
$flush();
}
}
$flush();
$labelQuery = $target->table('jst_order_labels')->where('company_id', $companyId);
if ($start !== null) {
$labelQuery->where('ordered_at', '>=', $start)->where('ordered_at', '<', $end);
}
$labels = $labelQuery->distinct()->count('label');
app(JstOrderLabelOptions::class)->forget($companyId);
app(JstOrderLabelOptions::class)->warm($companyId);
return [
'file' => basename($path),
'company_id' => $companyId,
'from' => $from,
'to' => $to,
'orders' => $orders,
'rows' => $rows,
'no_label_orders' => $noLabelOrders,
'skipped' => $skipped,
'labels' => (int) $labels,
'seconds' => round(microtime(true) - $startedAt, 1),
];
}
/** 下单时间归一成 Y-m-d H:i:s;解析不了就返回 null(该行跳过)。 */
private function normalizeOrderedAt(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
/** @return list<string> */
private function splitLabels(string $labels): array
{

View File

@ -1,59 +0,0 @@
<?php
namespace App\Business;
use Illuminate\Database\ConnectionInterface;
/**
* 订单「货号」查询:从聚水潭商品明细镜像按内部订单号取商品编码(sku_id)。
*
* 「被替换(Replaced)/取消(Cancelled、SentCancelled)」的明细行不算订单当前货号,
* 所以换过商品的订单(如信誉订单虚拟条码 X00006)只保留换入后的编码。
* 一单多个编码用英文逗号连接。
*/
class JstOrderLabelItemCodes
{
/** 商品明细状态:这些行不计入订单当前货号。 */
public const EXCLUDED_STATUSES = ['Replaced', 'Cancelled', 'SentCancelled'];
/**
* @param list<int|string> $orderIds 内部订单号(o_id)
* @return array<int,string> [内部订单号 => 货号]
*/
public function forOrderIds(ConnectionInterface $connection, string $itemsTable, array $orderIds): array
{
$orderIds = array_values(array_unique(array_filter(
array_map(static fn ($value): int => (int) $value, $orderIds),
static fn (int $value): bool => $value > 0,
)));
if ($orderIds === []) {
return [];
}
$rows = $connection->table($itemsTable)
->whereIn('o_id', $orderIds)
->where(function ($query): void {
$query->whereNull('item_status')
->orWhereNotIn('item_status', self::EXCLUDED_STATUSES);
})
->get(['o_id', 'sku_id']);
$skus = [];
foreach ($rows as $row) {
$sku = trim((string) $row->sku_id);
if ($sku === '') {
continue;
}
$skus[(int) $row->o_id][$sku] = true;
}
$codes = [];
foreach ($skus as $orderId => $list) {
$list = array_keys($list);
sort($list);
$codes[$orderId] = mb_substr(implode(',', $list), 0, 191);
}
return $codes;
}
}

View File

@ -10,9 +10,6 @@ use Illuminate\Support\Facades\Cache;
*
* 表里有 200 万行以上,distinct 扫描一次要几秒;这里按公司缓存 24 小时,
* 导入命令结束时会主动预热,避免每次打开页面都慢。
*
* 上限只是防呆(选项过多会拖慢页面),要高于实际店铺数:2026-10 导入 4~7 月后
* 店铺已达 604 个,原来卡在 500 会让一部分店铺选不出来。
*/
class JstOrderLabelOptions
{
@ -20,7 +17,7 @@ class JstOrderLabelOptions
private const TOTAL_TTL_MINUTES = 30;
private const LIMIT = 5000;
private const LIMIT = 500;
/** @return list<string> */
public function labels(int $companyId): array

View File

@ -9,9 +9,7 @@ use Illuminate\Support\Facades\Schema;
use Throwable;
/**
* 聚水潭订单标签明细的「是否种菜订单」判断:
* - 按(公司 + 标签)匹配财务维护的映射表;
* - 订单货号含信誉订单虚拟条码(X00006)的直接判为「是」。
* 聚水潭订单标签明细的「是否种菜订单」判断:按(公司 + 标签)匹配映射表。
*
* 映射表由财务维护(判断种菜单.xlsx)并按公司缓存,页面与导出共用同一份结果;
* 映射表缺失或未导入时不报错,统一按「否」展示。
@ -20,9 +18,6 @@ class JstOrderLabelPlantingRules
{
private const TTL_HOURS = 24;
/** 该货号(信誉订单虚拟条码)的订单直接判为种菜订单。 */
public const PLANTING_ITEM_CODE = 'X00006';
/** @return array<string,string> [标签 => 是/否] */
public function map(int $companyId): array
{
@ -51,38 +46,20 @@ class JstOrderLabelPlantingRules
});
}
/**
* 单条(标签 + 货号)的判断结果:标签命中映射表取其值;
* 货号含 X00006 的一律为「是」,其余未命中为「否」。
*/
public function flag(int $companyId, ?string $label, ?string $itemCode = null): string
/** 单个标签的判断结果:命中映射表取映射值(默认是),未命中为否。 */
public function flag(int $companyId, ?string $label): string
{
$key = trim((string) $label);
$flag = $key === '' ? '否' : ($this->map($companyId)[$key] ?? '否');
if ($flag === '否' && $this->hasPlantingItemCode($itemCode)) {
return '是';
if ($key === '') {
return '否';
}
return $flag;
}
/** 货号(可能多个,逗号连接)里是否含信誉订单虚拟条码。 */
private function hasPlantingItemCode(?string $itemCode): bool
{
foreach (explode(',', (string) $itemCode) as $code) {
if (mb_strtoupper(trim($code)) === self::PLANTING_ITEM_CODE) {
return true;
}
}
return false;
return $this->map($companyId)[$key] ?? '否';
}
public function forget(int $companyId): void
{
Cache::forget($this->key($companyId));
app(PlantingOrderLabelLookup::class)->forget($companyId);
}
private function key(int $companyId): string

View File

@ -1,68 +0,0 @@
<?php
namespace App\Business;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/** Reuse label classification across monthly/platform jobs; writers invalidate it. */
class PlantingOrderLabelLookup
{
/** @param list<string> $positiveLabels @return list<string> */
public function orderNumbers(int $companyId, array $positiveLabels): array
{
sort($positiveLabels, SORT_STRING);
$rules = hash('sha256', json_encode($positiveLabels, JSON_THROW_ON_ERROR));
$key = $this->key($companyId);
$read = function () use ($key, $rules, $companyId): ?array {
$cached = Cache::get($key);
return is_array($cached) && ($cached['rules'] ?? null) === $rules
&& ($cached['revision'] ?? null) === $this->revision($companyId)
? $cached['orders'] : null;
};
if (($orders = $read()) !== null) {
return $orders;
}
return Cache::lock($key . ':build', 300)->block(305, function () use ($companyId, $positiveLabels, $key, $rules, $read): array {
if (($orders = $read()) !== null) {
return $orders;
}
$revision = $this->revision($companyId);
$orders = [];
foreach (DB::table('jst_order_labels')->where('company_id', $companyId)
->where(function ($query) use ($positiveLabels): void {
if ($positiveLabels !== []) {
$query->whereIn('label', $positiveLabels);
}
$query->orWhereRaw('FIND_IN_SET(?, item_code) > 0', [JstOrderLabelPlantingRules::PLANTING_ITEM_CODE]);
})->select('order_no')->distinct()->cursor() as $row) {
$orders[] = (string) $row->order_no;
}
// An import/rule change during the scan must not publish an old result.
if ($this->revision($companyId) === $revision) {
Cache::put($key, ['rules' => $rules, 'revision' => $revision, 'orders' => $orders], now()->addHours(24));
}
return $orders;
});
}
public function forget(int $companyId): void
{
$key = $this->key($companyId);
Cache::store('upstream_shared')->forever($key . ':revision', bin2hex(random_bytes(16)));
Cache::forget($key);
}
public function revision(int $companyId): ?string
{
return Cache::store('upstream_shared')->get($this->key($companyId) . ':revision');
}
private function key(int $companyId): string
{
return 'planting-order-labels:v1:' . $companyId;
}
}

View File

@ -1,396 +0,0 @@
<?php
namespace App\Business;
use App\Models\Business\PlantingDetail;
use Illuminate\Database\DatabaseManager;
use InvalidArgumentException;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
/**
* 聚水潭「种菜报销」协同工单 → 种菜明细表(账单流水数据 › 种菜数据汇总 › 种菜明细表)月度同步。
*
* 口径(用 1~8 月已入库的 7 万多行反推、命中率 100%):
* - 只取「工单类型 = 种菜报销、工单状态 = 已完成」的工单;
* - 明细表「日期」= 工单完成日 − 1 天。所以某个月的数据 = 完成时间落在该月次日到下月次日的工单;
* - 一张工单一行,金额列与工单表单字段一一对应(见 FIELD_* 常量)。
*
* 排重:写入前先删掉「本月 + 本次订单号」的已有行,所以同一个月重复执行不会翻倍;
* 想整月覆盖(连没有对应工单的人工行一起清掉)用 --force-clear。
*
* 「所属利润中心」没有工单来源,按这个顺序取:
* 1. 表里已有的 店铺→利润中心 历史值(人工维护过的不覆盖);
* 2. 店铺维度报表的 ERP 系统店铺资料(ops_fys_shop_info.erpShopName → responsibleDepartment);
* 3. 两边都没有就留空,并在命令输出里列出来。
*/
class PlantingWorkOrderSynchronizer
{
/** 店铺维度报表用的店铺资料数据集(ERP系统店铺名称 → 店铺所属利润中心)。 */
public const SHOP_INFO_DATASET = 'operations_replica.rpt.fys_shopInfo';
/** 聚水潭协同工单镜像(jiansun.dbo.jst_workOrderInfo → js_jst_work_order_info)。 */
public const WORK_ORDER_DATASET = 'jiansun.dbo.jst_workOrderInfo';
/** 「种菜报销」这个工单类型的固定 id。 */
public const WORK_TYPE_ID = '6F03128403F2442CABCF283B59FAD88D';
/** 已完成的工单状态。 */
private const STATUS_CONFIRMED = 'CONFIRMED';
public const SOURCE = 'work-order-sync';
/** 工单表单字段(聚水潭只给 fieldId,不给字段名,这里的对应关系是拿历史数据逐列对撞出来的)。 */
private const FIELD_SELLING_PRICE = 'NUMBERField1413856620250924131615'; // 财务售价 = 任务付款金额
private const FIELD_FREIGHT = 'NUMBERField1413856620251113150537'; // 财务运费 = 任务快递费
private const FIELD_COMMISSION = 'NUMBERField1413856620251230154457'; // 财务佣金 = 任务佣金
private const FIELD_JINGXI_DIFF = 'NUMBERField1413856620250924131623'; // 京喜差价
private const FIELD_DEDUCTION = 'NUMBERField1413856620251113150612'; // 扣款 = 任务扣款费用
private const FIELD_EMPTY_PACKAGE = 'NUMBERField1413856620251113145620'; // 财务空包费 = 任务空包费
private const FIELD_PAYMENT = 'NUMBERField1413856620251117101250'; // 财务打款金额 = 最终报销金额
private const FIELD_OWNER = 'TEXTField1413856620251117180931'; // 负责人(收款人姓名)
private const FIELD_ACCOUNT = 'TEXTField1413856620251117113451'; // 账户(打款账号)
private const FIELD_TYPE = 'TEXTField1413856620251113150546'; // 类型(任务备注)
private const FIELD_TAG = 'businessRemarkField'; // 标签(业务备注)
private const FIELD_ORDER_NO = 'orderField'; // 订单编号(线上订单号)
private const FIELD_STORE = 'shopField'; // 正确店铺(店铺名称)
/** 金额列,统一按 4 位小数落库。 */
private const MONEY_COLUMNS = [
'finance_selling_price',
'finance_freight',
'finance_commission',
'jingxi_price_diff',
'deduction',
'finance_empty_package_fee',
'finance_payment_amount',
];
private const INSERT_CHUNK = 1000;
private const DELETE_CHUNK = 500;
public function __construct(
private readonly DatabaseManager $databases,
private readonly BusinessMirrorDatasetRegistry $datasets,
) {}
/**
* @return array{
* from:string,to:string,orders:int,inserted:int,deleted:int,
* selling_price:string,payment_amount:string,date_min:?string,date_max:?string,
* profit_center_from_erp:int,unmatched_stores:array<string,int>,dry_run:bool
* }
*/
public function sync(
string $from,
string $to,
int $companyId = 1,
bool $dryRun = false,
bool $forceClear = false,
): array {
$from = $this->normalizeDate($from, '起始日期');
$to = $this->normalizeDate($to, '结束日期');
if ($from > $to) {
throw new InvalidArgumentException('起始日期不能晚于结束日期。');
}
// 「日期」= 工单完成日 − 1,所以完成时间窗口整体后移一天。
$completeFrom = date('Y-m-d', strtotime($from . ' +1 day')) . ' 00:00:00';
$completeTo = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
$target = $this->databases->connection();
$mirror = $this->databases->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$table = $this->datasets->get(self::WORK_ORDER_DATASET)->targetTable();
if (! $mirror->getSchemaBuilder()->hasTable($table)) {
throw new RuntimeException('聚水潭协同工单镜像尚未建立(' . $table . ')。');
}
// 历史值优先(人工维护过的不覆盖),没有的再用 ERP 店铺资料补齐。
$storeToProfitCenter = $this->storeToProfitCenter($companyId);
$erpProfitCenters = $this->erpStoreToProfitCenter($mirror);
$now = now();
$rows = [];
$orders = 0;
$fromErp = 0;
$unmatchedStores = [];
$sellingPrice = 0.0;
$paymentAmount = 0.0;
$dateMin = null;
$dateMax = null;
$records = $mirror->table($table)
->where('woTypeId', self::WORK_TYPE_ID)
->where('status', self::STATUS_CONFIRMED)
->where('completeTime', '>=', $completeFrom)
->where('completeTime', '<', $completeTo)
->orderBy('completeTime')
->orderBy('woNo')
->get(['woNo', 'createby', 'completeTime', 'businessFieldList']);
foreach ($records as $record) {
$fields = $this->fields((string) $record->businessFieldList);
$orderNo = $this->text($fields[self::FIELD_ORDER_NO] ?? null);
if ($orderNo === null) {
continue;
}
$billDate = date('Y-m-d', strtotime((string) $record->completeTime . ' -1 day'));
if ($billDate < $from || $billDate > $to) {
continue;
}
$store = $this->text($fields[self::FIELD_STORE . '#label'] ?? null);
$profitCenter = null;
if ($store !== null) {
$profitCenter = $storeToProfitCenter[$store] ?? null;
if ($profitCenter === null && isset($erpProfitCenters[$store])) {
$profitCenter = $erpProfitCenters[$store];
$fromErp++;
}
}
if ($store !== null && $profitCenter === null) {
$unmatchedStores[$store] = ($unmatchedStores[$store] ?? 0) + 1;
}
$row = [
'company_id' => $companyId,
'bill_date' => $billDate,
'order_no' => mb_substr($orderNo, 0, 100),
'pieces' => 1,
'remark' => null,
'store' => $store,
'owner' => $this->text($fields[self::FIELD_OWNER] ?? null),
'account' => $this->text($fields[self::FIELD_ACCOUNT] ?? null),
'type' => $this->text($fields[self::FIELD_TYPE] ?? null),
'creator' => $this->text($record->createby),
'tag' => $this->text($fields[self::FIELD_TAG] ?? null),
'profit_center' => $profitCenter,
'responsible_platform' => null,
'external_id' => $this->text($this->platformItemId($fields), 100),
'created_from' => self::SOURCE,
'created_by' => null,
'created_at' => $now,
'updated_at' => $now,
];
foreach (self::MONEY_COLUMNS as $column) {
$row[$column] = number_format(0.0, 4, '.', '');
}
foreach ([
'finance_selling_price' => self::FIELD_SELLING_PRICE,
'finance_freight' => self::FIELD_FREIGHT,
'finance_commission' => self::FIELD_COMMISSION,
'jingxi_price_diff' => self::FIELD_JINGXI_DIFF,
'deduction' => self::FIELD_DEDUCTION,
'finance_empty_package_fee' => self::FIELD_EMPTY_PACKAGE,
'finance_payment_amount' => self::FIELD_PAYMENT,
] as $column => $field) {
$row[$column] = number_format($this->number($fields[$field] ?? null), 4, '.', '');
}
$orders++;
$sellingPrice += (float) $row['finance_selling_price'];
$paymentAmount += (float) $row['finance_payment_amount'];
$dateMin = $dateMin === null || $billDate < $dateMin ? $billDate : $dateMin;
$dateMax = $dateMax === null || $billDate > $dateMax ? $billDate : $dateMax;
$rows[] = $row;
}
$orderNumbers = array_values(array_unique(array_column($rows, 'order_no')));
$deleted = 0;
if (! $dryRun) {
$deleted = $this->clear($target, $companyId, $from, $to, $orderNumbers, $forceClear);
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
$target->table('planting_details')->insert($chunk);
}
}
arsort($unmatchedStores);
return [
'from' => $from,
'to' => $to,
'orders' => $orders,
'inserted' => $dryRun ? 0 : count($rows),
'deleted' => $deleted,
'selling_price' => number_format($sellingPrice, 4, '.', ''),
'payment_amount' => number_format($paymentAmount, 4, '.', ''),
'date_min' => $dateMin,
'date_max' => $dateMax,
'profit_center_from_erp' => $fromErp,
'unmatched_stores' => $unmatchedStores,
'dry_run' => $dryRun,
];
}
/**
* 排重:删掉本月内、这次要写入的那批订单号对应的已有行。
*
* @param list<string> $orderNumbers
*/
private function clear(
\Illuminate\Database\ConnectionInterface $target,
int $companyId,
string $from,
string $to,
array $orderNumbers,
bool $forceClear,
): int {
$base = static fn () => $target->table('planting_details')
->where('company_id', $companyId)
->where('bill_date', '>=', $from)
->where('bill_date', '<=', $to);
if ($forceClear) {
return (int) $base()->delete();
}
$deleted = 0;
foreach (array_chunk($orderNumbers, self::DELETE_CHUNK) as $chunk) {
$deleted += (int) $base()->whereIn('order_no', $chunk)->delete();
}
return $deleted;
}
/** @return array<string,string> 店铺 → 利润中心(同店铺多个取值时取出现次数最多的) */
private function storeToProfitCenter(int $companyId): array
{
$counts = [];
$rows = PlantingDetail::query()
->where('company_id', $companyId)
->whereNotNull('store')
->whereNotNull('profit_center')
->selectRaw('store, profit_center, COUNT(*) AS total')
->groupBy('store', 'profit_center')
->get();
foreach ($rows as $row) {
$store = (string) $row->store;
$counts[$store][(string) $row->profit_center] = (int) $row->total;
}
$map = [];
foreach ($counts as $store => $values) {
arsort($values);
$map[$store] = (string) array_key_first($values);
}
return $map;
}
/**
* 店铺维度报表的 ERP 系统店铺 → 店铺所属利润中心(ops_fys_shop_info.responsibleDepartment)。
*
* @return array<string,string>
*/
private function erpStoreToProfitCenter(\Illuminate\Database\ConnectionInterface $mirror): array
{
$table = $this->datasets->get(self::SHOP_INFO_DATASET)->targetTable();
if (! $mirror->getSchemaBuilder()->hasTable($table)) {
return [];
}
$map = [];
foreach ($mirror->table($table)
->whereNotNull('erpShopName')
->where('responsibleDepartment', '<>', '')
->get(['erpShopName', 'responsibleDepartment']) as $row) {
$name = trim((string) $row->erpShopName);
$department = trim((string) $row->responsibleDepartment);
if ($name !== '' && $department !== '' && ! isset($map[$name])) {
$map[$name] = mb_substr($department, 0, 191);
}
}
return $map;
}
/** @return array<string,mixed> fieldId → value(带 valueLabel 的另存为 fieldId#label) */
private function fields(string $businessFieldList): array
{
$fields = [];
$items = json_decode($businessFieldList, true);
if (! is_array($items)) {
return $fields;
}
foreach ($items as $item) {
if (! is_array($item) || ! isset($item['fieldId'])) {
continue;
}
if ($item['fieldId'] === 'itemInfo') {
// 商品明细是嵌套结构,原样留着给 platformItemId 取平台商品 ID。
$fields['itemInfo'] = $item;
continue;
}
$value = $item['value'] ?? null;
if (is_array($value)) {
$value = implode(',', array_map('strval', $value));
}
$fields[(string) $item['fieldId']] = $value;
if (($item['valueLabel'] ?? null) !== null) {
$fields[(string) $item['fieldId'] . '#label'] = $item['valueLabel'];
}
}
return $fields;
}
/** @param array<string,mixed> $fields */
private function platformItemId(array $fields): ?string
{
$itemInfo = $fields['itemInfo'] ?? null;
if (! is_array($itemInfo)) {
return null;
}
foreach ((array) ($itemInfo['childFieldList'] ?? []) as $childRow) {
foreach ((array) $childRow as $child) {
if (is_array($child) && ($child['fieldId'] ?? null) === 'platformItemId') {
return $this->text($child['value'] ?? null, 100);
}
}
}
return null;
}
private function text(mixed $value, int $limit = 191): ?string
{
if ($value === null) {
return null;
}
$text = trim((string) (is_scalar($value) ? $value : ''));
return $text === '' ? null : mb_substr($text, 0, $limit);
}
private function number(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
return is_numeric($value) ? (float) $value : 0.0;
}
private function normalizeDate(string $value, string $label): string
{
$value = trim($value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
throw new InvalidArgumentException($label . '必须是 YYYY-MM-DD。');
}
[$year, $month, $day] = array_map('intval', explode('-', $value));
if (! checkdate($month, $day, $year)) {
throw new InvalidArgumentException($label . '不是有效日期。');
}
return $value;
}
}

View File

@ -1,261 +0,0 @@
<?php
namespace App\Business;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
/**
* 汇总商品月加权平均单价结果,并将入库明细差异按原因分类。
*/
class ProductMonthlyWeightedAverageReporter
{
// 异常阈值未由业务另行指定:取至少 100 行,且不超过总行数的 0.1%。
private const ANOMALY_THRESHOLD_MIN_ROWS = 100;
private const ANOMALY_THRESHOLD_RATE = 0.001;
private const ROW_TABLE = 'product_monthly_weighted_average_rows';
private const STATE_TABLE = 'product_monthly_weighted_average_states';
private const INBOUND_TABLE = 'inventory_inbound_detail_rows';
private const INVENTORY_MONTH_TABLE = 'inventory_report_authoritative_months';
private const INVENTORY_ROW_TABLE = 'inventory_report_authoritative_rows';
/** @return array<string,mixed> */
public function build(string $connection, CarbonImmutable $month): array
{
$month = $month->startOfMonth();
$db = DB::connection($connection);
$baseQuery = static fn () => $db->table(self::ROW_TABLE)
->where('report_month', $month->toDateString());
$totals = $baseQuery()
->selectRaw('count(*) as total_rows')
->selectRaw('coalesce(sum(opening_quantity), 0) as opening_quantity')
->selectRaw('coalesce(sum(opening_amount), 0) as opening_amount')
->selectRaw('coalesce(sum(purchase_quantity), 0) as purchase_quantity')
->selectRaw('coalesce(sum(purchase_amount), 0) as purchase_amount')
->selectRaw('sum(case when weighted_average_unit_price is not null then 1 else 0 end) as weighted_price_rows')
->selectRaw('sum(case when weighted_average_unit_price is null then 1 else 0 end) as missing_weighted_rows')
->selectRaw('sum(case when pricing_status = ? then 1 else 0 end) as manual_review_rows', ['待人工核查'])
->first();
if ($totals === null || (int) $totals->total_rows === 0) {
return [
'status' => 'missing',
'report_month' => $month->toDateString(),
'message' => '该月份尚未生成商品月加权平均单价。',
'generated_at' => now()->toDateTimeString(),
];
}
$weightedKeys = [];
foreach ($baseQuery()->select('product_code', 'warehouse_short_name')->cursor() as $row) {
$weightedKeys[$this->key($row->product_code, $row->warehouse_short_name)] = true;
}
$inbound = [
'source_rows' => 0,
'source_quantity' => 0.0,
'source_amount' => 0.0,
'matched_rows' => 0,
'matched_quantity' => 0.0,
'matched_amount' => 0.0,
'unmatched_rows' => 0,
'unmatched_quantity' => 0.0,
'unmatched_amount' => 0.0,
'unmatched_groups' => [],
];
foreach ($db->table(self::INBOUND_TABLE)
->where('report_month', $month->toDateString())
->selectRaw('upper(barcode) as code, warehouse_short_name, count(*) as rows_count')
->selectRaw('coalesce(sum(quantity), 0) as quantity')
->selectRaw('coalesce(sum(coalesce(amount, 0) + coalesce(estimated_amount, 0)), 0) as amount')
->groupByRaw('upper(barcode), warehouse_short_name')
->cursor() as $row) {
$rows = (int) $row->rows_count;
$quantity = (float) $row->quantity;
$amount = (float) $row->amount;
$inbound['source_rows'] += $rows;
$inbound['source_quantity'] += $quantity;
$inbound['source_amount'] += $amount;
if (isset($weightedKeys[$this->key($row->code, $row->warehouse_short_name)])) {
$inbound['matched_rows'] += $rows;
$inbound['matched_quantity'] += $quantity;
$inbound['matched_amount'] += $amount;
continue;
}
$inbound['unmatched_rows'] += $rows;
$inbound['unmatched_quantity'] += $quantity;
$inbound['unmatched_amount'] += $amount;
$inbound['unmatched_groups'][] = [
'product_code' => trim((string) $row->code),
'warehouse_short_name' => trim((string) $row->warehouse_short_name),
'source_rows' => $rows,
'quantity' => $this->number($quantity),
'amount' => $this->number($amount),
'reason' => '入库明细未匹配到商品编码 + 仓库简称行',
];
}
usort(
$inbound['unmatched_groups'],
static fn (array $left, array $right): int =>
abs((float) $right['amount']) <=> abs((float) $left['amount']),
);
$inbound = $this->roundNestedNumbers($inbound);
$monthState = $db->table(self::INVENTORY_MONTH_TABLE)
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
->where('source_month', $month->toDateString())
->first();
$generation = $monthState?->active_generation;
$actualSourceRows = $generation === null
? 0
: $db->table(self::INVENTORY_ROW_TABLE)
->where('generation', (string) $generation)
->count();
$expectedSourceRows = (int) ($monthState?->source_row_count ?? 0);
$weightedAnomalyRows = $baseQuery()
->where(function ($query): void {
$query->whereNull('weighted_average_unit_price')
->orWhere('pricing_status', '待人工核查');
})
->count();
$reasons = [];
if ((int) $totals->missing_weighted_rows > 0) {
$reasons[] = [
'code' => 'missing_weighted_price',
'label' => '加权平均单价为空',
'rows' => (int) $totals->missing_weighted_rows,
];
}
if ((int) $totals->manual_review_rows > 0) {
$reasons[] = [
'code' => 'manual_review',
'label' => '待人工核查',
'rows' => (int) $totals->manual_review_rows,
];
}
if ((int) $inbound['unmatched_rows'] > 0) {
$reasons[] = [
'code' => 'inbound_without_inventory_dimension',
'label' => '入库明细未匹配到商品编码 + 仓库简称行',
'rows' => (int) $inbound['unmatched_rows'],
'groups' => count($inbound['unmatched_groups']),
];
}
if ($actualSourceRows !== $expectedSourceRows) {
$reasons[] = [
'code' => 'upstream_source_row_mismatch',
'label' => '上游进销存源表行数与已激活明细不一致',
'rows' => abs($actualSourceRows - $expectedSourceRows),
];
}
$anomalyRows = $weightedAnomalyRows + (int) $inbound['unmatched_rows'];
$threshold = max(
self::ANOMALY_THRESHOLD_MIN_ROWS,
(int) ceil((int) $totals->total_rows * self::ANOMALY_THRESHOLD_RATE),
);
return [
'status' => $reasons === [] ? 'computed' : 'review_required',
'report_month' => $month->toDateString(),
'totals' => [
'rows' => (int) $totals->total_rows,
'opening_quantity' => $this->number((float) $totals->opening_quantity),
'opening_amount' => $this->number((float) $totals->opening_amount),
'purchase_quantity' => $this->number((float) $totals->purchase_quantity),
'purchase_amount' => $this->number((float) $totals->purchase_amount),
'weighted_price_rows' => (int) $totals->weighted_price_rows,
'missing_weighted_rows' => (int) $totals->missing_weighted_rows,
'manual_review_rows' => (int) $totals->manual_review_rows,
],
'inbound' => $inbound,
'source' => [
'expected_rows' => $expectedSourceRows,
'active_rows' => $actualSourceRows,
'row_difference' => $actualSourceRows - $expectedSourceRows,
'status' => (string) ($monthState?->status ?? 'missing'),
'active_generation' => $generation,
'source_filename' => $monthState?->source_filename,
],
'anomalies' => [
'affected_rows' => $anomalyRows,
'threshold_rows' => $threshold,
'threshold_exceeded' => $anomalyRows > $threshold,
'reasons' => $reasons,
],
'state' => $db->table(self::STATE_TABLE)
->where('report_month', $month->toDateString())
->first(),
'generated_at' => now()->toDateTimeString(),
];
}
/** @param array<string,mixed> $report */
public function brief(array $report): string
{
if (($report['status'] ?? null) === 'missing') {
return (string) ($report['message'] ?? '商品月加权平均单价尚未生成');
}
$totals = $report['totals'] ?? [];
$inbound = $report['inbound'] ?? [];
$anomalies = $report['anomalies'] ?? [];
$reasonLabels = array_map(
static fn (array $reason): string =>
(string) ($reason['label'] ?? $reason['code'] ?? '未知异常'),
$anomalies['reasons'] ?? [],
);
return sprintf(
'%s:总行数 %s;期初 %s 件 / %s;入库 %s 件 / %s;加权单价 %s 行;异常 %s 行%s',
substr((string) ($report['report_month'] ?? ''), 0, 7),
number_format((int) ($totals['rows'] ?? 0)),
number_format((float) ($totals['opening_quantity'] ?? 0), 2),
number_format((float) ($totals['opening_amount'] ?? 0), 2),
number_format((float) ($inbound['matched_quantity'] ?? 0), 2),
number_format((float) ($inbound['matched_amount'] ?? 0), 2),
number_format((int) ($totals['weighted_price_rows'] ?? 0)),
number_format((int) ($anomalies['affected_rows'] ?? 0)),
$reasonLabels === [] ? '' : '(' . implode('、', $reasonLabels) . ')',
);
}
private function key(mixed $productCode, mixed $warehouseShortName): string
{
return mb_strtoupper(trim((string) $productCode))
. "\0"
. trim((string) $warehouseShortName);
}
private function number(float $value): float
{
return round($value, 6);
}
/**
* @param array<string,mixed> $value
* @return array<string,mixed>
*/
private function roundNestedNumbers(array $value): array
{
foreach ($value as $key => $item) {
if (is_array($item)) {
$value[$key] = $this->roundNestedNumbers($item);
} elseif (is_float($item)) {
$value[$key] = round($item, 6);
}
}
return $value;
}
}

View File

@ -1,273 +0,0 @@
<?php
namespace App\Business;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreViolationRuleRepository;
use Throwable;
/**
* 店铺违规信息预警:预警级别、责任人、处理截止等派生字段的计算。
*
* - 级别/是否提醒:按「平台 + 违规类型」匹配违规字典;原文先去(…)括号处置说明,
* 再做 相等/前缀/关键词 匹配;匹配不到 = 不需要提醒(只入库不推送)。
* - 责任人:店铺账号镜像的运营负责人(与店铺维度报表同口径),
* 未命中时回退店铺档案(ops_fys_shop_info.shopHead)。
* - 处理截止:申诉截止日期非空→当天 23:59:59;否则按字典时效(小时)或
* 级别默认天数(P0+1 / P1+2 / P2+3 天)的当天 23:59:59。
*/
class StoreViolationAlertCalculator
{
/** @var list<array<string,mixed>>|null */
private ?array $rules = null;
/** @var array<string,array{operation_owner:string,operation_director:string,profit_center:string}>|null */
private ?array $ownerMap = null;
/** @var array<string,?string> */
private array $ownerFallback = [];
public function __construct(
private readonly StoreViolationRuleRepository $ruleRepository,
private readonly StoreAccountMirrorRepository $storeAccounts,
) {
}
public function flush(): void
{
$this->rules = null;
$this->ownerMap = null;
$this->ownerFallback = [];
}
/** @return array<string,mixed> 预警相关字段(用于同步/导入写入) */
public function calculate(
string $platform,
?string $shopName,
?string $violationType,
?string $violationDate,
?string $appealDeadline,
): array {
$rule = self::matchRule($this->rules(), $platform, $violationType);
$needsAlert = $rule !== null && (bool) ($rule['needs_alert'] ?? true);
$level = null;
if ($needsAlert) {
$level = trim((string) ($rule['alert_level'] ?? ''));
if ($level === '' || ! array_key_exists($level, (array) config('store_violations.alert_levels', []))) {
$level = 'P2';
}
}
$hours = isset($rule['handling_hours']) && $rule['handling_hours'] !== null
? (int) $rule['handling_hours']
: null;
return [
'needs_alert' => $needsAlert,
'alert_level' => $level,
'alert_rule_id' => $needsAlert ? ($rule['id'] ?? null) : null,
'alert_action' => $needsAlert ? self::nullableText($rule['suggested_action'] ?? null, 500) : null,
'alert_channel' => $needsAlert ? self::nullableText($rule['remind_channel'] ?? null, 64) : null,
'handling_hours' => $needsAlert ? $hours : null,
'owner_name' => $this->ownerFor($shopName),
'handling_deadline' => self::deadlineFor($violationDate, $appealDeadline, $hours, $level),
];
}
/** @return list<array<string,mixed>> */
private function rules(): array
{
if ($this->rules === null) {
try {
$this->rules = $this->ruleRepository->activeRules();
} catch (Throwable) {
$this->rules = [];
}
}
return $this->rules;
}
/**
* 清理违规类型原文:反复去掉成对括号(含处置说明)并去除空白。
*/
public static function cleanViolationType(?string $text): string
{
$text = trim((string) $text);
if ($text === '') {
return '';
}
$previous = null;
while ($previous !== $text) {
$previous = $text;
$text = preg_replace('/[((【\[][^((【\[))】\]]*[))】\]]/u', '', $text) ?? $text;
}
return preg_replace('/[\s\x{3000}]+/u', '', $text) ?? $text;
}
private static function loose(string $text): string
{
return preg_replace('/[\/、,,。.;;::·\-—_]+/u', '', $text) ?? $text;
}
/**
* 按顺序匹配字典:相等 → 前缀 → 分词包含 → 宽松包含 → 关键词包含。
*
* @param list<array<string,mixed>> $rules 已按优先级排序
* @return array<string,mixed>|null
*/
public static function matchRule(array $rules, string $platform, ?string $violationType): ?array
{
$record = self::cleanViolationType($violationType);
if ($record === '') {
return null;
}
$looseRecord = self::loose($record);
foreach ($rules as $rule) {
if (trim((string) ($rule['platform'] ?? '')) !== $platform) {
continue;
}
$ruleType = self::cleanViolationType($rule['violation_type'] ?? null);
if ($ruleType !== '') {
if ($record === $ruleType || str_starts_with($record, $ruleType)) {
return $rule;
}
foreach (preg_split('/[\/、]/u', $ruleType) ?: [] as $part) {
$part = trim((string) $part);
if ($part !== '' && mb_strlen($part) >= 2 && mb_stripos($record, $part) !== false) {
return $rule;
}
}
$looseRule = self::loose($ruleType);
if ($looseRule !== '' && str_contains($looseRecord, $looseRule)) {
return $rule;
}
}
foreach (preg_split('/[|、,\/]/u', (string) ($rule['match_keywords'] ?? '')) ?: [] as $keyword) {
$keyword = self::loose(self::cleanViolationType($keyword));
if ($keyword !== '' && str_contains($looseRecord, $keyword)) {
return $rule;
}
}
}
return null;
}
/**
* 处理截止:申诉截止优先;否则字典时效(小时)或级别默认天数(当天 23:59:59)。
*/
public static function deadlineFor(
?string $violationDate,
?string $appealDeadline,
?int $handlingHours,
?string $alertLevel,
): ?string {
$appeal = trim((string) $appealDeadline);
if ($appeal !== '') {
try {
return Carbon::parse($appeal)->endOfDay()->toDateTimeString();
} catch (Throwable) {
// 解析失败时退回按级别计算
}
}
$date = trim((string) $violationDate);
if ($date === '') {
return null;
}
try {
$base = Carbon::parse($date)->startOfDay();
} catch (Throwable) {
return null;
}
if ($handlingHours !== null && $handlingHours > 0) {
return $base->addHours($handlingHours)->toDateTimeString();
}
$level = trim((string) $alertLevel);
if ($level === '') {
return null;
}
$days = (int) (config('store_violations.alert_default_days.' . $level) ?? 0);
return $days > 0 ? $base->addDays($days)->endOfDay()->toDateTimeString() : null;
}
/** 是否超时:处理状态不在终态且当前时间已过处理截止。 */
public static function isOverdue(mixed $handlingStatus, mixed $handlingDeadline, ?Carbon $now = null): bool
{
$status = trim((string) $handlingStatus);
if (in_array($status, ['处理完成', '已忽略', '已处理', '无需处理'], true)) {
return false;
}
$deadline = trim((string) $handlingDeadline);
if ($deadline === '') {
return false;
}
try {
return ($now ?? Carbon::now())->greaterThan(Carbon::parse($deadline));
} catch (Throwable) {
return false;
}
}
private function ownerFor(?string $shopName): ?string
{
$name = trim((string) $shopName);
if ($name === '') {
return null;
}
try {
if ($this->ownerMap === null) {
$this->ownerMap = $this->storeAccounts->ownershipByStoreName();
}
$owner = trim((string) ($this->ownerMap[$name]['operation_owner'] ?? ''));
if ($owner !== '') {
return mb_substr($owner, 0, 64);
}
} catch (Throwable) {
$this->ownerMap ??= [];
}
// 兜底:店铺档案(经营分析副本库)的店铺运营负责人。
if (array_key_exists($name, $this->ownerFallback)) {
return $this->ownerFallback[$name];
}
$head = null;
try {
$row = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('ops_fys_shop_info')
->where('shopName', $name)
->orderByDesc('id')
->first(['shopHead']);
$head = self::nullableText($row->shopHead ?? null, 64);
} catch (Throwable) {
$head = null;
}
return $this->ownerFallback[$name] = $head;
}
private static function nullableText(mixed $value, int $limit): ?string
{
$text = trim((string) $value);
return $text === '' ? null : mb_substr($text, 0, $limit);
}
}

View File

@ -72,9 +72,6 @@ class StoreViolationMapper
'shop_name' => mb_substr(trim((string) ($row['shop_name'] ?? '')), 0, 191),
'violation_type' => $content === '' ? null : mb_substr($content, 0, 500),
'violation_category' => $this->categoryForContent($content),
// 上游视图补齐这两列后由同步命令自动带出(见 SyncStoreViolations 的列探测)。
'violation_reason' => $this->normalizeLabel($row['violation_reason'] ?? null, 500),
'disposition' => $this->normalizeLabel($row['disposition'] ?? null, 500),
'violation_no' => mb_substr(trim((string) ($row['violation_no'] ?? '')), 0, 100),
'violation_date' => $this->normalizeDate($row['violation_date'] ?? null),
'status' => $this->normalizeLabel($row['status'] ?? null, 64),

View File

@ -1,237 +0,0 @@
<?php
namespace App\Business;
use App\Models\Common\Company;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\URL;
use Modules\EcommerceFinance\Repositories\StoreViolationPushRepository;
use RuntimeException;
use Throwable;
/**
* 店铺违规信息预警:内部群机器人推送(先留接口,配置 Webhook 后启用)。
*
* - P0/P1 即时推送(每天 09:00 起由定时任务检查),消息含去认领签名链接;
* - P2 不即时推,每日 10:00 汇总成一条日报;
* - 以违规记录为唯一对象:已推送的不再重复推送(失败可显式重试)。
*/
class StoreViolationNotifier
{
public function __construct(private readonly StoreViolationPushRepository $pushes)
{
}
public function enabled(): bool
{
$config = (array) config('store_violations.push', []);
return (bool) ($config['enabled'] ?? false)
&& trim((string) ($config['webhook'] ?? '')) !== '';
}
/**
* @return array{attempted:int,succeeded:int,failed:int}
*/
public function pushInstant(int $limit = 200, bool $retryFailed = false): array
{
$levels = array_values(array_map('strval', (array) config('store_violations.push.instant_levels', ['P0', 'P1'])));
$candidates = $this->pushes->instantCandidates($levels, $retryFailed, $limit);
$summary = ['attempted' => $candidates->count(), 'succeeded' => 0, 'failed' => 0];
$ownerContacts = (array) config('store_violations.push.owner_contacts', []);
foreach ($candidates as $record) {
$owner = trim((string) ($record->owner_name ?? ''));
$atMobiles = [];
if ($owner !== '' && ! empty($ownerContacts[$owner])) {
$atMobiles[] = (string) $ownerContacts[$owner];
}
[$title, $text] = $this->instantMessage($record);
$pushNo = $this->pushNo();
$now = Carbon::now();
try {
$this->send($title, $text, $atMobiles);
$this->pushes->markPushed([(int) $record->id], $now);
$summary['succeeded']++;
$this->pushes->log([
'push_no' => $pushNo,
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'instant',
'platform' => (string) ($record->platform ?? ''),
'violation_no' => (string) ($record->violation_no ?? ''),
'message_digest' => mb_substr($title, 0, 500),
'mentioned' => $owner,
'result' => 'success',
'error' => null,
]);
} catch (Throwable $exception) {
$summary['failed']++;
$this->pushes->markPushFailed((int) $record->id, $now);
$this->pushes->log([
'push_no' => $pushNo,
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'instant',
'platform' => (string) ($record->platform ?? ''),
'violation_no' => (string) ($record->violation_no ?? ''),
'message_digest' => mb_substr($title, 0, 500),
'mentioned' => $owner,
'result' => 'failed',
'error' => mb_substr($exception->getMessage(), 0, 500),
]);
}
}
return $summary;
}
/**
* @return array{pending:array<string,int>,overdue:int,p2:int,sent:bool}
*/
public function pushDigest(): array
{
$data = $this->pushes->digestData();
$pending = $data['pending'];
$overdue = $data['overdue'];
$p2Ids = $data['p2_pending']->pluck('id')->map(static fn ($id): int => (int) $id)->all();
$lines = [
'### 店铺违规预警 · 每日汇总(' . Carbon::now()->format('m-d') . ')',
'- 待处理:P0 ' . (int) ($pending['P0'] ?? 0)
. ' 条 / P1 ' . (int) ($pending['P1'] ?? 0)
. ' 条 / P2 ' . (int) ($pending['P2'] ?? 0) . ' 条',
'- 已超时未认领:' . $overdue->count() . ' 条',
];
foreach ($overdue as $index => $record) {
$lines[] = sprintf(
' %d. [%s] %s(责任人:%s) · 截止 %s',
$index + 1,
(string) ($record->alert_level ?? ''),
(string) ($record->shop_name ?? ''),
trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配',
(string) ($record->handling_deadline ?? ''),
);
}
$now = Carbon::now();
try {
$this->send('店铺违规预警 · 每日汇总', implode("\n", $lines), []);
} catch (Throwable $exception) {
$this->pushes->log([
'push_no' => $this->pushNo(),
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'daily',
'platform' => null,
'violation_no' => null,
'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500),
'mentioned' => null,
'result' => 'failed',
'error' => mb_substr($exception->getMessage(), 0, 500),
]);
return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => false];
}
// 汇总成功后把已点名的 P2 记录标记为已推送;未点名的明细汇总不重复提醒。
$this->pushes->markPushed($p2Ids, $now);
$this->pushes->log([
'push_no' => $this->pushNo(),
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'daily',
'platform' => null,
'violation_no' => null,
'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500),
'mentioned' => null,
'result' => 'success',
'error' => null,
]);
return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => true];
}
/** @return array{0:string,1:string} */
private function instantMessage(object $record): array
{
$level = (string) ($record->alert_level ?? '');
$levelLabel = (string) (config('store_violations.alert_levels.' . $level . '.label') ?? $level);
$platformLabel = (string) (config('store_violations.platforms.' . (string) ($record->platform ?? '') . '.label') ?? ($record->platform ?? ''));
$title = '店铺违规预警(' . $levelLabel . ')';
$lines = [
'### 【' . $levelLabel . '】店铺违规预警',
'- 平台/店铺:' . $platformLabel . ' / ' . (string) ($record->shop_name ?? ''),
'- 违规类型:' . mb_substr((string) ($record->violation_type ?? ''), 0, 200),
'- 处罚措施:' . (trim((string) ($record->disposition ?? '')) !== '' ? (string) $record->disposition : '—'),
'- 处理建议:' . (trim((string) ($record->alert_action ?? '')) !== '' ? (string) $record->alert_action : '按平台规则处理'),
'- 责任人:' . (trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配'),
'- 处理截止:' . (string) ($record->handling_deadline ?? '—'),
];
$claimUrl = $this->claimUrl((int) $record->id);
if ($claimUrl !== null) {
$lines[] = '- [去认领](' . $claimUrl . ')(认领后处理状态自动变为「处理中」)';
}
return [$title, implode("\n", $lines)];
}
private function claimUrl(int $recordId): ?string
{
try {
$companyId = (int) (Company::query()->orderBy('id')->value('id') ?? 1);
return URL::signedRoute('business.store-violation-alert.claim', [
'record' => $recordId,
'company_id' => $companyId,
]);
} catch (Throwable) {
return null;
}
}
/** @param list<string> $atMobiles */
private function send(string $title, string $text, array $atMobiles): void
{
$config = (array) config('store_violations.push', []);
$webhook = trim((string) ($config['webhook'] ?? ''));
if ($webhook === '') {
throw new RuntimeException('推送 Webhook 未配置。');
}
$url = $webhook;
$secret = trim((string) ($config['secret'] ?? ''));
if ($secret !== '') {
$timestamp = (string) (int) (microtime(true) * 1000);
$sign = urlencode(base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true)));
$url .= (str_contains($url, '?') ? '&' : '?') . 'timestamp=' . $timestamp . '&sign=' . $sign;
}
$response = Http::timeout(10)->post($url, [
'msgtype' => 'markdown',
'markdown' => ['title' => $title, 'text' => $text],
'at' => [
'atMobiles' => array_values($atMobiles),
'isAtAll' => (bool) ($config['at_all'] ?? false),
],
]);
if (! $response->successful()) {
throw new RuntimeException('推送接口返回 HTTP ' . $response->status());
}
$body = (array) $response->json();
if ((int) ($body['errcode'] ?? 0) !== 0) {
throw new RuntimeException('推送接口错误:' . (string) ($body['errcode'] ?? '?') . ' ' . (string) ($body['errmsg'] ?? ''));
}
}
private function pushNo(): string
{
return 'VP' . Carbon::now()->format('ymdHis') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 6));
}
}

View File

@ -19,7 +19,6 @@ class StoreViolationWorkbookImporter
public function __construct(
private readonly StoreViolationMapper $mapper,
private readonly StoreViolationRecordRepository $repository,
private readonly StoreViolationAlertCalculator $alerts,
) {
}
@ -140,11 +139,6 @@ class StoreViolationWorkbookImporter
}
$updateColumns[] = 'source';
$updateColumns[] = 'updated_at';
// 预警派生字段(级别/责任人/处理截止等):导入时按当前字典自动计算写入。
$updateColumns = array_values(array_unique(array_merge(
$updateColumns,
StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS,
)));
$payload = [];
$created = 0;
@ -161,17 +155,6 @@ class StoreViolationWorkbookImporter
continue;
}
$matchType = trim((string) ($data['violation_type'] ?? '')) !== ''
? (string) $data['violation_type']
: ($data['violation_reason'] ?? null);
$data = array_merge($data, $this->alerts->calculate(
$platform,
(string) ($data['shop_name'] ?? ''),
$matchType,
$data['violation_date'] ?? null,
$data['appeal_deadline'] ?? null,
));
foreach (array_values(array_diff($updateColumns, ['source', 'updated_at'])) as $column) {
if (! array_key_exists($column, $data)) {
$data[$column] = null;
@ -322,25 +305,13 @@ class StoreViolationWorkbookImporter
$data['violation_type'] = $type === '' ? null : mb_substr($type, 0, 500);
}
$reason = $has('violation_reason') ? trim((string) ($raw['violation_reason'] ?? '')) : null;
if ($has('violation_reason')) {
$data['violation_reason'] = $reason === '' ? null : mb_substr($reason, 0, 500);
}
if ($has('disposition')) {
$disposition = trim((string) ($raw['disposition'] ?? ''));
$data['disposition'] = $disposition === '' ? null : mb_substr($disposition, 0, 500);
}
if ($has('violation_category')) {
$categorySource = ($type !== null && $type !== '') ? $type : (string) $reason;
$category = trim((string) ($raw['violation_category'] ?? ''));
$data['violation_category'] = $category === ''
? $this->mapper->categoryForContent($categorySource)
? $this->mapper->categoryForContent($type)
: mb_substr($category, 0, 64);
} elseif ($has('violation_type') || $has('violation_reason')) {
$categorySource = ($type !== null && $type !== '') ? $type : (string) $reason;
$data['violation_category'] = $this->mapper->categoryForContent($categorySource);
} elseif ($has('violation_type')) {
$data['violation_category'] = $this->mapper->categoryForContent($type);
}
if ($has('violation_date')) {
@ -386,8 +357,6 @@ class StoreViolationWorkbookImporter
if ($has('handling_status')) {
$handling = trim((string) ($raw['handling_status'] ?? ''));
$legacyMap = (array) config('store_violations.handling_status_legacy', []);
$handling = (string) ($legacyMap[$handling] ?? $handling);
$options = (array) config('store_violations.handling_statuses', []);
if ($handling === '') {
$data['handling_status'] = (string) config('store_violations.default_handling_status', '未处理');
@ -399,15 +368,6 @@ class StoreViolationWorkbookImporter
}
}
if ($has('ignore_reason')) {
$ignoreReason = trim((string) ($raw['ignore_reason'] ?? ''));
$data['ignore_reason'] = $ignoreReason === '' ? null : mb_substr($ignoreReason, 0, 191);
}
if (($data['handling_status'] ?? null) === '已忽略' && trim((string) ($data['ignore_reason'] ?? '')) === '') {
$errors[] = '处理状态为“已忽略”时必须填写忽略原因';
}
return [
'row_number' => $rowNumber,
'status' => $errors === [] ? 'valid' : 'invalid',

View File

@ -1,939 +0,0 @@
<?php
namespace App\Business;
use App\Models\Business\InventoryWarehouseNameMapping;
use DateTimeImmutable;
use DOMDocument;
use DOMXPath;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
use Throwable;
use XMLReader;
use ZipArchive;
/**
* 供应商汇总表月度增量导入。
*
* 仅追加目标月份的新行,不删除其他月份。内容相同的源明细按出现次序分配
* 稳定标识后全部保留;重复执行时只跳过已经写入的同一源行。
* 单行字段异常会进入失败清单,不阻断其他有效行。
*/
class SupplierSummaryInboundImporter
{
private const TABLE = 'inventory_inbound_detail_rows';
private const DETAIL_SHEET = '明细';
/** @var array<string,list<string>> */
private const REQUIRED_HEADERS = [
'source_no' => ['来源单号'],
'document_no' => ['单据编号(采购单号)', '单据编号(采购单号)', '单据编号'],
'supplier' => ['供应商'],
'item_no' => ['货号'],
'barcode' => ['条码'],
'spec_name' => ['规格名'],
'quantity' => ['库存变动量'],
'unit_price' => ['单价'],
'amount' => ['金额'],
'estimated_unit_price' => ['预估单价'],
'estimated_amount' => ['预估金额'],
'supplier_barcode' => ['供应商条码'],
'remark' => ['备注'],
'warehouse' => ['仓库'],
];
/** @var array<string,list<string>> */
private const OPTIONAL_HEADERS = [
'express_name' => ['快递名称'],
'express_no' => ['快递单号'],
'not_sent' => ['未发出'],
'owner_name' => ['负责人'],
];
/**
* @return array{
* status:string,
* report_month:string,
* source_filename:string,
* success:int,
* duplicates:int,
* failed:int,
* mapped:int,
* total_in_table:int,
* failure_reason_count:int,
* failure_reasons:list<array{row:int,reason:string}>
* }
*/
public function import(string $file, string $reportMonth): array
{
$path = realpath($file);
if ($path === false || ! is_file($path) || ! is_readable($path)) {
throw new RuntimeException('供应商汇总文件不存在或不可读取。');
}
$month = $this->normalizeMonth($reportMonth);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$maps = $this->shortNameMap();
[$reader, $sharedStrings] = $this->streamDetailSheet($path);
try {
$success = 0;
$duplicates = 0;
$mapped = 0;
$failed = 0;
$reasons = [];
$occurrences = [];
$batch = [];
DB::connection($connection)->transaction(function () use (
$connection,
$reader,
$sharedStrings,
$maps,
$month,
&$success,
&$duplicates,
&$mapped,
&$failed,
&$reasons,
&$occurrences,
&$batch,
): void {
$flush = function () use (
$connection,
$month,
&$success,
&$duplicates,
&$batch,
): void {
if ($batch === []) {
return;
}
$keys = array_column($batch, 'row_key');
$existing = DB::connection($connection)
->table(self::TABLE)
->where('report_month', $month)
->whereIn('row_key', $keys)
->pluck('row_key')
->map(static fn (mixed $key): string => (string) $key)
->all();
$existing = array_fill_keys($existing, true);
$insertable = [];
foreach ($batch as $record) {
if (isset($existing[$record['row_key']])) {
$duplicates++;
continue;
}
$insertable[] = $record;
}
if ($insertable !== []) {
$inserted = DB::connection($connection)
->table(self::TABLE)
->insertOrIgnore($insertable);
$success += (int) $inserted;
$duplicates += count($insertable) - (int) $inserted;
}
$batch = [];
};
$headerRow = null;
$columns = [];
$headerCandidates = [];
while ($reader->read()) {
if (
$reader->nodeType !== XMLReader::ELEMENT
|| $reader->name !== 'row'
) {
continue;
}
$rowNumber = (int) $reader->getAttribute('r');
$values = $this->readRow($reader, $sharedStrings);
if ($headerRow === null) {
$headerCandidates[$rowNumber] = $values;
$header = $this->headerColumns($headerCandidates);
if ($header !== null) {
[$headerRow, $columns] = $header;
$headerCandidates = [];
continue;
}
if ($rowNumber >= 20) {
throw new RuntimeException(
'「明细」子表前 20 行未找到供应商汇总标准表头。',
);
}
continue;
}
if ($rowNumber <= $headerRow) {
continue;
}
if ($this->emptyRow($values)) {
continue;
}
try {
$record = $this->record(
$values,
$columns,
$maps,
$month,
$rowNumber,
);
} catch (Throwable $exception) {
$failed++;
$reasons[] = [
'row' => $rowNumber,
'reason' => $exception->getMessage(),
];
continue;
}
$baseKey = (string) $record['row_key'];
$occurrence = ($occurrences[$baseKey] ?? 0) + 1;
$occurrences[$baseKey] = $occurrence;
if ($occurrence > 1) {
$record['row_key'] = hash(
'sha256',
$baseKey . '|occurrence:' . $occurrence,
);
}
$mapped++;
$batch[] = $record;
if (count($batch) >= 500) {
$flush();
}
}
if ($headerRow === null) {
throw new RuntimeException(
'「明细」子表前 20 行未找到供应商汇总标准表头。',
);
}
$flush();
});
$status = $failed === 0
? 'success'
: ($success > 0 ? 'partial' : 'failed');
return [
'status' => $status,
'report_month' => $month,
'source_filename' => basename($path),
'success' => $success,
'duplicates' => $duplicates,
'failed' => $failed,
'mapped' => $mapped,
'total_in_table' => DB::connection($connection)
->table(self::TABLE)
->where('report_month', $month)
->count(),
'failure_reason_count' => count($reasons),
'failure_reasons' => $reasons,
];
} finally {
$reader->close();
}
}
private function normalizeMonth(string $month): string
{
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
throw new RuntimeException('月份格式应为 YYYY-MM。');
}
return $month . '-01';
}
/**
* @param array<int,array<int,mixed>> $rows
* @return array{0:int,1:array<string,int>}|null
*/
private function headerColumns(array $rows): ?array
{
ksort($rows);
foreach ($rows as $row => $values) {
$positions = [];
foreach ($values as $index => $value) {
$heading = $this->normalizeHeader(
(string) $this->plainValue($value),
);
if ($heading !== '') {
$positions[$heading][] = $index;
}
}
$columns = [];
foreach (self::REQUIRED_HEADERS as $field => $aliases) {
$position = $this->firstHeaderPosition($positions, $aliases);
if ($position !== null) {
$columns[$field] = $position;
}
}
$createdPositions = $positions[$this->normalizeHeader('创建时间')] ?? [];
if (count($createdPositions) >= 2) {
$columns['created_serial'] = $createdPositions[0];
$columns['created_at_raw'] = $createdPositions[1];
}
if (count($columns) < count(self::REQUIRED_HEADERS) + 2) {
continue;
}
foreach (self::OPTIONAL_HEADERS as $field => $aliases) {
$position = $this->firstHeaderPosition($positions, $aliases);
if ($position !== null) {
$columns[$field] = $position;
}
}
foreach (['created_serial', 'created_at_raw'] as $field) {
if (! isset($columns[$field])) {
throw new RuntimeException('「明细」表头缺少第二个「创建时间」列。');
}
}
return [$row, $columns];
}
return null;
}
/** @param array<string,list<int>> $positions @param list<string> $aliases */
private function firstHeaderPosition(array $positions, array $aliases): ?int
{
foreach ($aliases as $alias) {
$heading = $this->normalizeHeader($alias);
if (isset($positions[$heading][0])) {
return $positions[$heading][0];
}
}
return null;
}
private function normalizeHeader(string $heading): string
{
return preg_replace('/[\s\x{3000}]+/u', '', trim($heading)) ?? trim($heading);
}
/**
* @param array<int,mixed> $values
* @param array<string,int> $columns
* @param array{warehouse:array<string,string>,provider:array<string,string>} $maps
* @return array<string,mixed>
*/
private function record(
array $values,
array $columns,
array $maps,
string $month,
int $rowNumber,
): array {
$text = fn (string $field): ?string => $this->text(
$values[$columns[$field] ?? -1] ?? null,
);
$number = function (string $label, string $field) use (
$values,
$columns,
$rowNumber,
): ?float {
$value = $values[$columns[$field] ?? -1] ?? null;
if (is_array($value)) {
$formula = trim((string) ($value['__formula'] ?? ''));
if ($formula !== '') {
return $this->formulaValue(
$label,
$field,
$formula,
$rowNumber,
$values,
$columns,
);
}
$value = $value['__cached'] ?? null;
}
$text = str_replace(
[',', ','],
'',
trim((string) $this->plainValue($value)),
);
if ($text === '') {
return null;
}
if (! is_numeric($text)) {
throw new RuntimeException("字段「{$label}」不是有效数字:" . $text);
}
return (float) $text;
};
$barcode = $text('barcode');
$warehouse = $text('warehouse');
if ($barcode === null) {
throw new RuntimeException('商品编码(条码)为空');
}
if ($warehouse === null) {
throw new RuntimeException('仓库为空');
}
$shortName = $maps['warehouse'][$warehouse]
?? $maps['provider'][$warehouse]
?? null;
if ($shortName === null || trim($shortName) === '') {
throw new RuntimeException('仓库未匹配到唯一仓库简称:' . $warehouse);
}
$createdRaw = $this->moment($values[$columns['created_at_raw']] ?? null);
if ($createdRaw === null) {
throw new RuntimeException('第二个「创建时间」为空或格式无效');
}
$record = [
'report_month' => $month,
'source_no' => $text('source_no'),
'document_no' => $text('document_no'),
'created_serial' => $text('created_serial'),
'supplier' => $text('supplier'),
'item_no' => $text('item_no'),
'barcode' => mb_strtoupper($barcode),
'spec_name' => $text('spec_name'),
'quantity' => $number('库存变动量', 'quantity'),
'unit_price' => $number('单价', 'unit_price'),
'amount' => $number('金额', 'amount'),
'estimated_unit_price' => $number('预估单价', 'estimated_unit_price'),
'estimated_amount' => $number('预估金额', 'estimated_amount'),
'supplier_barcode' => $text('supplier_barcode'),
'remark' => $text('remark'),
'warehouse' => $warehouse,
'warehouse_short_name' => $shortName,
'created_at_raw' => $createdRaw,
'express_name' => $text('express_name'),
'express_no' => $text('express_no'),
'not_sent' => $text('not_sent'),
'owner_name' => $text('owner_name'),
];
unset($record['warehouse_short_name']);
$record['row_key'] = hash(
'sha256',
json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
);
$record['warehouse_short_name'] = $shortName;
$record['imported_at'] = now();
return $record;
}
/**
* 仅接受金额类字段当前行内的乘积公式,避免执行来源文件中的任意公式。
*
* @param array<int,mixed> $values
* @param array<string,int> $columns
*/
private function formulaValue(
string $label,
string $field,
string $formula,
int $rowNumber,
array $values,
array $columns,
): float {
$expectedFields = match ($field) {
'amount' => ['quantity', 'unit_price'],
'estimated_amount' => ['estimated_unit_price', 'quantity'],
default => [],
};
if ($expectedFields === []) {
throw new RuntimeException(
"字段「{$label}」不允许使用公式:" . $formula,
);
}
if (
preg_match(
'/^=?\s*\$?([A-Z]{1,3})\$?(\d+)\s*\*\s*\$?([A-Z]{1,3})\$?(\d+)\s*$/i',
$formula,
$matches,
) !== 1
) {
throw new RuntimeException(
"字段「{$label}」公式无法安全识别:" . $formula,
);
}
if ((int) $matches[2] !== $rowNumber || (int) $matches[4] !== $rowNumber) {
throw new RuntimeException(
"字段「{$label}」公式引用了其他行:" . $formula,
);
}
$actualColumns = [
$this->columnIndex($matches[1]),
$this->columnIndex($matches[3]),
];
$expectedColumns = [
$columns[$expectedFields[0]] + 1,
$columns[$expectedFields[1]] + 1,
];
sort($actualColumns);
sort($expectedColumns);
if ($actualColumns !== $expectedColumns) {
throw new RuntimeException(
"字段「{$label}」公式与取数口径不一致:" . $formula,
);
}
$left = $this->numericReference(
$values[$actualColumns[0] - 1] ?? null,
$label,
);
$right = $this->numericReference(
$values[$actualColumns[1] - 1] ?? null,
$label,
);
return $left * $right;
}
private function numericReference(mixed $value, string $label): float
{
if (is_array($value)) {
if (($value['__formula'] ?? '') !== '') {
throw new RuntimeException(
"字段「{$label}」的公式引用了另一个公式单元格",
);
}
$value = $value['__cached'] ?? null;
}
$text = str_replace(
[',', ','],
'',
trim((string) $this->plainValue($value)),
);
if (! is_numeric($text)) {
throw new RuntimeException(
"字段「{$label}」的公式引用值不是有效数字:" . $text,
);
}
return (float) $text;
}
/** @param array<int,mixed> $values */
private function emptyRow(array $values): bool
{
foreach ($values as $value) {
if (trim((string) $this->plainValue($value)) !== '') {
return false;
}
}
return true;
}
private function text(mixed $value): ?string
{
$text = trim((string) $this->plainValue($value));
return $text === '' ? null : $text;
}
private function plainValue(mixed $value): mixed
{
if (is_array($value)) {
return $value['__cached'] ?? null;
}
return $value;
}
private function moment(mixed $value): ?string
{
$value = $this->plainValue($value);
if ($value === null || trim((string) $value) === '') {
return null;
}
if (is_numeric($value)) {
try {
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
$value = trim((string) $value);
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
$date = DateTimeImmutable::createFromFormat($format, $value);
if ($date instanceof DateTimeImmutable) {
return $date->format('Y-m-d H:i:s');
}
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
private function shortNameMap(): array
{
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
$grouped = [];
foreach ($rows as $row) {
$warehouse = trim((string) $row->warehouse);
if ($warehouse !== '') {
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
}
}
$byWarehouse = [];
foreach ($grouped as $warehouse => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byWarehouse[$warehouse] = $unique[0];
}
}
$providerGrouped = [];
foreach ($rows as $row) {
$provider = trim((string) $row->warehouse_provider);
if ($provider !== '') {
$providerGrouped[$provider][] = [
'warehouse' => trim((string) $row->warehouse),
'short_name' => trim((string) $row->warehouse_short_name),
];
}
}
$byProvider = [];
foreach ($providerGrouped as $provider => $providerRows) {
$unique = array_values(array_unique(array_column(
$providerRows,
'short_name',
)));
if (count($unique) === 1 && $unique[0] !== '') {
$byProvider[$provider] = $unique[0];
continue;
}
$mainShortNames = [];
foreach ($providerRows as $providerRow) {
if (str_contains($providerRow['warehouse'], '主仓')) {
$mainShortNames[] = $providerRow['short_name'];
}
}
$mainShortNames = array_values(array_unique($mainShortNames));
if (count($mainShortNames) === 1 && $mainShortNames[0] !== '') {
$byProvider[$provider] = $mainShortNames[0];
}
}
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
}
/** @return array{0:XMLReader,1:list<string>} */
private function streamDetailSheet(string $path): array
{
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException('供应商汇总文件不是有效的 XLSX 工作簿。');
}
try {
$sheetPath = $this->detailSheetPath($zip);
$sharedStrings = $zip->locateName('xl/sharedStrings.xml') !== false
? $this->readSharedStrings($path)
: [];
} finally {
$zip->close();
}
return [
$this->openZipEntry($path, $sheetPath),
$sharedStrings,
];
}
private function detailSheetPath(ZipArchive $zip): string
{
$workbookXml = $zip->getFromName('xl/workbook.xml');
$relationshipsXml = $zip->getFromName('xl/_rels/workbook.xml.rels');
if ($workbookXml === false || $relationshipsXml === false) {
throw new RuntimeException('供应商汇总工作簿缺少工作簿结构文件。');
}
$workbook = new DOMDocument();
$relationships = new DOMDocument();
if (
! $workbook->loadXML($workbookXml, LIBXML_NONET)
|| ! $relationships->loadXML($relationshipsXml, LIBXML_NONET)
) {
throw new RuntimeException('供应商汇总工作簿结构无法读取。');
}
$relationshipId = null;
$xpath = new DOMXPath($workbook);
$xpath->registerNamespace(
'main',
'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
);
$xpath->registerNamespace(
'office',
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
);
foreach ($xpath->query('/main:workbook/main:sheets/main:sheet') ?: [] as $sheet) {
if ($sheet instanceof \DOMElement
&& $sheet->getAttribute('name') === self::DETAIL_SHEET
) {
$relationshipId = $sheet->getAttributeNS(
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
'id',
);
break;
}
}
if ($relationshipId === null || $relationshipId === '') {
throw new RuntimeException('供应商汇总表缺少「明细」子表。');
}
$target = null;
foreach ($relationships->getElementsByTagName('Relationship') as $relationship) {
if ($relationship->getAttribute('Id') === $relationshipId) {
$target = $relationship->getAttribute('Target');
break;
}
}
if ($target === null || $target === '') {
throw new RuntimeException('「明细」子表的工作簿关系不存在。');
}
return $this->normalizeZipPath(
str_starts_with($target, '/') ? ltrim($target, '/') : 'xl/' . $target,
);
}
private function normalizeZipPath(string $path): string
{
$segments = [];
foreach (explode('/', str_replace('\\', '/', $path)) as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..') {
array_pop($segments);
continue;
}
$segments[] = $segment;
}
return implode('/', $segments);
}
/** @return list<string> */
private function readSharedStrings(string $path): array
{
$reader = $this->openZipEntry($path, 'xl/sharedStrings.xml');
$strings = [];
$current = null;
$stringDepth = null;
$phoneticDepth = null;
try {
while ($reader->read()) {
if (
$reader->nodeType === XMLReader::ELEMENT
&& $reader->name === 'si'
) {
$current = '';
$stringDepth = $reader->depth;
$phoneticDepth = null;
continue;
}
if ($current === null) {
continue;
}
if (
$reader->nodeType === XMLReader::ELEMENT
&& $reader->name === 'rPh'
) {
$phoneticDepth = $reader->depth;
continue;
}
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'rPh'
&& $reader->depth === $phoneticDepth
) {
$phoneticDepth = null;
continue;
}
if (
$reader->nodeType === XMLReader::ELEMENT
&& $reader->name === 't'
&& $phoneticDepth === null
) {
$current .= $reader->readString();
continue;
}
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'si'
&& $reader->depth === $stringDepth
) {
$strings[] = $current;
$current = null;
}
}
} finally {
$reader->close();
}
return $strings;
}
private function openZipEntry(string $path, string $entry): XMLReader
{
$reader = new XMLReader();
$flags = LIBXML_NONET | LIBXML_COMPACT;
if (! $reader->open("zip://{$path}#{$entry}", null, $flags)) {
throw new RuntimeException("无法读取工作簿内容:{$entry}");
}
return $reader;
}
/**
* @param list<string> $sharedStrings
* @return array<int,mixed>
*/
private function readRow(XMLReader $reader, array $sharedStrings): array
{
$values = [];
$rowDepth = $reader->depth;
while ($reader->read()) {
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'row'
&& $reader->depth === $rowDepth
) {
break;
}
if (
$reader->nodeType !== XMLReader::ELEMENT
|| $reader->name !== 'c'
) {
continue;
}
$reference = $reader->getAttribute('r') ?? '';
if (
preg_match('/^([A-Z]{1,3})\d+$/i', $reference, $matches) !== 1
) {
continue;
}
$values[$this->columnIndex($matches[1]) - 1] = $this->readCell(
$reader,
$sharedStrings,
);
}
return $values;
}
/** @param list<string> $sharedStrings */
private function readCell(XMLReader $reader, array $sharedStrings): mixed
{
if ($reader->isEmptyElement) {
return null;
}
$type = $reader->getAttribute('t') ?? '';
$cellDepth = $reader->depth;
$cached = null;
$formula = null;
$hasFormula = false;
$inlineParts = [];
$phoneticDepth = null;
while ($reader->read()) {
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'c'
&& $reader->depth === $cellDepth
) {
break;
}
if ($reader->nodeType === XMLReader::ELEMENT) {
if ($reader->name === 'f') {
$hasFormula = true;
$formula = $reader->readString();
continue;
}
if ($reader->name === 'v') {
$cached = $reader->readString();
continue;
}
if ($reader->name === 'rPh') {
$phoneticDepth = $reader->depth;
continue;
}
if (
$reader->name === 't'
&& $phoneticDepth === null
) {
$inlineParts[] = $reader->readString();
}
}
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'rPh'
&& $reader->depth === $phoneticDepth
) {
$phoneticDepth = null;
}
}
$value = match ($type) {
's' => is_numeric($cached)
? ($sharedStrings[(int) $cached] ?? null)
: null,
'inlineStr' => implode('', $inlineParts),
'b' => $cached === null ? null : ($cached === '1' ? '1' : '0'),
default => $cached ?? implode('', $inlineParts),
};
if ($hasFormula) {
return [
'__formula' => (string) $formula,
'__cached' => $value,
];
}
return $value;
}
private function columnIndex(string $column): int
{
$index = 0;
foreach (str_split(strtoupper($column)) as $letter) {
$index = ($index * 26) + (ord($letter) - 64);
}
return $index;
}
}

View File

@ -17,7 +17,6 @@ use App\Models\Business\StoreChange;
use App\Models\Business\StoreChangeLog;
use App\Models\Business\StoreClosing;
use App\Models\Business\StoreInformation;
use App\Models\Business\ShopInfoFlow;
use App\Models\Business\StoreInvoice;
use App\Models\Business\StoreOpening;
use App\Models\Business\StoreReceivingAccount;
@ -172,7 +171,6 @@ class TableRegistry
'fund-management/shared-company-accounts' => BankAccount::class, // 重用 BankAccount,仅类型过滤
'billing-transactions/store-receiving-balance' => StoreReceivingAccount::class,
'financial-operations/installation-details' => InstallationDetail::class,
'financial-risk-control/process-statistics' => ShopInfoFlow::class,
];
}
@ -262,7 +260,6 @@ class TableRegistry
'store-management/store-report',
'trademark-management/information',
'financial-operations/installation-details',
'financial-risk-control/process-statistics',
], true);
}

View File

@ -40,7 +40,6 @@ class UpstreamBusinessInformation
'company-management/subject-operating-report' => $this->subjectOperatingReportPage($currentPage, $perPage),
'trademark-management/information' => $this->trademarkPage($currentPage, $perPage),
'financial-operations/installation-details' => $this->installationPage($currentPage, $perPage),
'financial-risk-control/process-statistics' => $this->shopInfoFlowPage($currentPage, $perPage),
default => ['data' => [], 'total' => 0],
};
@ -56,26 +55,6 @@ class UpstreamBusinessInformation
);
}
/**
* 财务经营风险管控 › 流程统计:店铺申请流程(开店 / 变更 / 关店)只读镜像。
*
* @return array{data:list<array<string,mixed>>,total:int}
*/
private function shopInfoFlowPage(int $page, int $perPage): array
{
try {
return app(\Modules\EcommerceFinance\Repositories\ShopInfoFlowMirrorRepository::class)->paginate(
$page,
$perPage,
$this->pageFilters('financial-risk-control', 'process-statistics'),
);
} catch (\RuntimeException $exception) {
report($exception);
throw new ServiceUnavailableHttpException(null, '店铺申请流程共享镜像尚未完成首次同步。');
}
}
private function installationPage(int $page, int $perPage): array
{
$payload = $this->installationMirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters());
@ -667,7 +646,7 @@ SQL);
});
}
/** @return array<string,string|list<string>> */
/** @return array<string,string> */
private function pageFilters(string $section, string $page): array
{
$fields = collect(config('business_navigation'))
@ -682,20 +661,7 @@ SQL);
$filters = [];
foreach ($allowed as $name) {
foreach ([$name, $name . '_from', $name . '_to'] as $key) {
$raw = request($key, '');
// 多选筛选(xxx[])传进来是数组:去重后原样带下去,由各自的仓库取并集。
if (is_array($raw)) {
$values = [];
foreach ($raw as $item) {
if (! is_scalar($item)) continue;
$item = mb_substr(trim((string) $item), 0, 191);
if ($item !== '') $values[$item] = true;
if (count($values) >= 50) break;
}
if ($values !== []) $filters[$key] = array_keys($values);
continue;
}
$value = trim((string) $raw);
$value = trim((string) request($key, ''));
if ($value !== '') $filters[$key] = mb_substr($value, 0, 191);
}
}

View File

@ -3,7 +3,6 @@
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
@ -31,12 +30,12 @@ class WithdrawalManagementReport
/**
* @return array{
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string},
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string},
* platforms:list<string>,paths:list<string>,storeEntities:list<string>,rows:Collection<int,StoreReceivingAccount>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
* summary:array{source:int,matched:int,ambiguous:int,unmatched:int,not_ready:int},
* balanceColumnLabel:string,balanceDate:string,balanceDates:list<string>,sort:array{field:string,direction:string}
* balanceColumnLabel:string,balanceDate:string,sort:array{field:string,direction:string}
* }
*/
public function results(Request $request): array
@ -62,14 +61,7 @@ class WithdrawalManagementReport
}
$today = now()->startOfDay();
// 余额快照按日期长期保留:默认显示最新余额日期,也可指定历史日期回看。
$latestBalanceDate = $this->shopBalances->latestBalanceDate($today);
$balanceDate = $filters['balanceDate'] !== ''
? $filters['balanceDate']
: ($latestBalanceDate ?? $today->copy()->subDay()->toDateString());
$labelDate = $filters['balanceDate'] !== ''
? CarbonImmutable::parse($balanceDate)
: $today;
$balanceDate = $today->copy()->subDay()->toDateString();
$rows = $query->orderByDesc('id')->get();
$balances = $this->shopBalances->matchesForStores($rows, $balanceDate);
$balanceCounts = collect($balances)->countBy(
@ -125,28 +117,21 @@ class WithdrawalManagementReport
'unmatched' => (int) $balanceCounts->get('unmatched', 0),
'not_ready' => (int) $balanceCounts->get('not_ready', 0),
],
'balanceColumnLabel' => $labelDate->format('n月j日') . '店铺余额',
'balanceColumnLabel' => $today->format('n月j日') . '店铺余额',
'balanceDate' => $balanceDate,
'balanceDates' => $this->shopBalances->availableDates($today),
'sort' => $sort,
];
}
/** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string} */
/** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string} */
public function filters(Request $request): array
{
$clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
$balanceStatus = $clean($request->query('balance_status'));
$balanceDate = $clean($request->query('balance_date'), 10);
if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) {
$balanceStatus = '';
}
$validDate = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $balanceDate, $parts) === 1
&& checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1]);
if (! $validDate || $balanceDate > now()->toDateString()) {
$balanceDate = '';
}
return [
'platform' => $clean($request->query('platform')),
@ -154,7 +139,6 @@ class WithdrawalManagementReport
'storeEntity' => $clean($request->query('store_entity'), 191),
'keyword' => $clean($request->query('keyword'), 191),
'balanceStatus' => $balanceStatus,
'balanceDate' => $balanceDate,
];
}

View File

@ -13,7 +13,6 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
@ -31,11 +30,6 @@ class WithdrawalManagementSpreadsheet
private const CLEAR_VALUE = '[清空]';
public function __construct(
private readonly JushuitanShopBalanceRepository $shopBalances,
) {
}
/**
* @param array{
* rows:\Illuminate\Support\Collection<int,StoreReceivingAccount>,
@ -69,7 +63,7 @@ class WithdrawalManagementSpreadsheet
['1. 店铺平台、ERP店铺名称用于定位店铺;有平台店铺ID时优先按平台和ID匹配。'],
['2. 店铺主体、提现路径、提现账户名称、银行卡号后四位和提现金额为可更新字段。'],
['3. 可更新字段留空表示保持原值,填写“' . self::CLEAR_VALUE . '”表示清空原值。'],
['4. 提现金额须为非负数,最多两位小数,只用于当前页面显示的余额数据日期(最近 5 天内最新可用日期);上传后先预览再确认。'],
['4. 提现金额须为非负数,最多两位小数,只用于导入当天对应的余额数据日期;上传后先预览再确认。'],
], null, 'A1');
$instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14);
$instructions->getColumnDimension('A')->setWidth(88);
@ -103,7 +97,7 @@ class WithdrawalManagementSpreadsheet
}
[$headerIndex, $headers] = $this->headers($matrix);
$indexes = array_flip($headers);
$balanceDate = $this->currentBalanceDate();
$balanceDate = now()->subDay()->toDateString();
$accounts = StoreReceivingAccount::withoutGlobalScopes()
->where('company_id', $companyId)
->whereNull('deleted_at')
@ -227,7 +221,6 @@ class WithdrawalManagementSpreadsheet
{
return DB::transaction(function () use ($plan, $companyId): int {
$updated = 0;
$currentBalanceDate = null;
foreach ($plan as $item) {
/** @var StoreReceivingAccount|null $account */
@ -245,13 +238,11 @@ class WithdrawalManagementSpreadsheet
}
if (isset($item['changes']['withdrawal_balance_date'])
&& $item['changes']['withdrawal_balance_date'] !== null) {
$currentBalanceDate ??= $this->currentBalanceDate();
if ($item['changes']['withdrawal_balance_date'] !== $currentBalanceDate) {
throw ValidationException::withMessages([
'import' => '余额日期已滚动,请重新上传并预览提现金额。',
]);
}
&& $item['changes']['withdrawal_balance_date'] !== null
&& $item['changes']['withdrawal_balance_date'] !== now()->subDay()->toDateString()) {
throw ValidationException::withMessages([
'import' => '余额日期已滚动,请重新上传并预览提现金额。',
]);
}
foreach ($item['changes'] as $field => $value) {
@ -432,14 +423,6 @@ class WithdrawalManagementSpreadsheet
return $changes;
}
private function currentBalanceDate(): string
{
$today = now()->startOfDay();
return $this->shopBalances->latestBalanceDate($today)
?? $today->copy()->subDay()->toDateString();
}
private function key(string $platform, string $identity): string
{
return trim($platform) . "\0" . trim($identity);

View File

@ -1,324 +0,0 @@
<?php
namespace App\Business;
use Generator;
use RuntimeException;
use ZipArchive;
/**
* xlsx 工作表流式读取器(按表头取列)。
*
* 主要给几十万行的大表用(聚水潭导出订单、快递运费明细等),直接套常规 Excel 读法会踩坑:
* - 字符串用 inlineStr 内联存储(sharedStrings 里是空的);
* - worksheet 的 <dimension> 只写一个 A1,靠它判断行数会把整表读成空;
* - 一行订单一个商品明细,单文件常见上万行 × 上百列,解压后几十 MB。
*
* 所以这里用 ZipArchive 取压缩流 + expat 增量解析,只保留调用方点名的列,
* 内存占用与文件大小无关(导出 20 万行也不会整表进内存)。
*/
class XlsxSheetReader
{
private const READ_CHUNK = 262144;
/**
* 逐行读取导出文件(跳过表头行)。
*
* @param list<string|list<string>> $columns 需要取出的列(缺列会报错)。给字符串就是精确匹配表头;
* 给数组表示同一列有多个可选表头(取第一个命中的,
* 返回键用数组里的第一个名字)。
* @param list<string|list<string>> $optionalColumns 可选列:文件里有就取,没有就跳过(不报错)。
* @return Generator<int, array<string, string>>
*/
public function rows(string $path, array $columns, array $optionalColumns = []): Generator
{
if (! is_file($path)) {
throw new RuntimeException('订单导出文件不存在:' . $path);
}
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException('无法打开订单导出文件(不是有效的 xlsx):' . $path);
}
$stream = $zip->getStream($this->worksheetPath($zip));
if ($stream === false) {
$zip->close();
throw new RuntimeException('订单导出文件里没有可读的工作表。');
}
try {
yield from $this->parseSheet($stream, $this->sharedStrings($zip), $columns, $optionalColumns);
} finally {
fclose($stream);
$zip->close();
}
}
/**
* @param resource $stream
* @param list<string> $shared
* @param list<string|list<string>> $columns
* @param list<string|list<string>> $optionalColumns
* @return Generator<int, array<string, string>>
*/
private function parseSheet($stream, array $shared, array $columns, array $optionalColumns): Generator
{
$state = [
'row' => [],
'column' => -1,
'type' => null,
'value' => '',
'inValue' => false,
'rowNumber' => 0,
'wanted' => [],
'pending' => [],
];
$parser = xml_parser_create('UTF-8');
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
xml_set_element_handler(
$parser,
static function ($parser, string $name, array $attributes) use (&$state): void {
switch ($name) {
case 'row':
$state['row'] = [];
$state['column'] = -1;
break;
case 'c':
$state['column'] = self::columnIndex($attributes['r'] ?? null, $state['column'] + 1);
$state['type'] = $attributes['t'] ?? null;
$state['value'] = '';
break;
case 'v':
case 't':
$state['inValue'] = true;
break;
}
},
static function ($parser, string $name) use (&$state, $shared, $columns, $optionalColumns): void {
switch ($name) {
case 'v':
case 't':
$state['inValue'] = false;
break;
case 'c':
$state['row'][$state['column']] = self::cellValue($state['type'], $state['value'], $shared);
break;
case 'row':
$state['rowNumber']++;
if ($state['rowNumber'] === 1) {
$state['wanted'] = self::wantedColumns($state['row'], $columns, $optionalColumns);
break;
}
$values = [];
foreach ($state['wanted'] as $index => $column) {
$values[$column] = $state['row'][$index] ?? '';
}
$state['pending'][] = $values;
break;
}
}
);
xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$state): void {
if ($state['inValue']) {
$state['value'] .= $data;
}
});
$first = true;
try {
while (! feof($stream)) {
$chunk = fread($stream, self::READ_CHUNK);
if ($chunk === false || $chunk === '') {
break;
}
if ($first) {
// 聚水潭导出的 XML 带 UTF-8 BOM,expat 会当成非法前缀。
$chunk = preg_replace('/^\xEF\xBB\xBF/', '', $chunk, 1);
$first = false;
}
if (xml_parse($parser, $chunk, false) === 0) {
throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser)));
}
while ($state['pending'] !== []) {
yield array_shift($state['pending']);
}
}
if (xml_parse($parser, '', true) === 0) {
throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser)));
}
} finally {
xml_parser_free($parser);
}
while ($state['pending'] !== []) {
yield array_shift($state['pending']);
}
}
/**
* 表头行 -> [列下标 => 列名],并校验需要的列都在;可选列缺了不算错。
*
* @param array<int, string> $header
* @param list<string|list<string>> $columns
* @param list<string|list<string>> $optionalColumns
* @return array<int, string>
*/
private static function wantedColumns(array $header, array $columns, array $optionalColumns = []): array
{
$byName = [];
foreach ($header as $index => $name) {
$name = trim($name);
if ($name !== '' && ! isset($byName[$name])) {
$byName[$name] = $index;
}
}
$wanted = [];
foreach ($columns as $column) {
$names = is_array($column) ? array_values($column) : [$column];
$matched = null;
foreach ($names as $name) {
if (isset($byName[$name])) {
$matched = $name;
break;
}
}
if ($matched === null) {
throw new RuntimeException('文件缺少列:' . implode(' / ', $names));
}
$wanted[$byName[$matched]] = $names[0];
}
foreach ($optionalColumns as $column) {
$names = is_array($column) ? array_values($column) : [$column];
$matched = null;
foreach ($names as $name) {
if (isset($byName[$name])) {
$matched = $name;
break;
}
}
if ($matched !== null && ! isset($wanted[$byName[$matched]])) {
$wanted[$byName[$matched]] = $names[0];
}
}
return $wanted;
}
/**
* @param list<string> $shared
*/
private static function cellValue(?string $type, string $value, array $shared): string
{
if ($type === 's') {
return $shared[(int) $value] ?? '';
}
return trim($value);
}
/** 单元格引用(如 AV12)-> 0 基列下标。 */
private static function columnIndex(?string $reference, int $fallback): int
{
if ($reference === null || preg_match('/^([A-Z]+)/i', $reference, $matches) !== 1) {
return $fallback;
}
$letters = strtoupper($matches[1]);
$index = 0;
for ($i = 0, $length = strlen($letters); $i < $length; $i++) {
$index = $index * 26 + (ord($letters[$i]) - 64);
}
return $index - 1;
}
private function worksheetPath(ZipArchive $zip): string
{
$workbook = $zip->getFromName('xl/workbook.xml');
$relationships = $zip->getFromName('xl/_rels/workbook.xml.rels');
if (is_string($workbook) && is_string($relationships)
&& preg_match('/<sheet[^>]*r:id="([^"]+)"/', $workbook, $sheet) === 1
&& preg_match('/Id="' . preg_quote($sheet[1], '/') . '"[^>]*Target="([^"]+)"/', $relationships, $target) === 1) {
$path = ltrim($target[1], '/');
return str_starts_with($path, 'xl/') ? $path : 'xl/' . $path;
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string) $zip->getNameIndex($i);
if (preg_match('#^xl/worksheets/[^/]+\.xml$#', $name) === 1) {
return $name;
}
}
return 'xl/worksheets/sheet1.xml';
}
/**
* 读取共享字符串表;聚水潭导出通常为空,但标准 xlsx 会用到。
*
* @return list<string>
*/
private function sharedStrings(ZipArchive $zip): array
{
$xml = $zip->getFromName('xl/sharedStrings.xml');
if (! is_string($xml) || $xml === '') {
return [];
}
$out = [];
$index = -1;
$buffer = '';
$inside = false;
$parser = xml_parser_create('UTF-8');
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
xml_set_element_handler(
$parser,
static function ($parser, string $name) use (&$inside, &$buffer, &$index): void {
if ($name === 'si') {
$index++;
$buffer = '';
} elseif ($name === 't') {
$inside = true;
}
},
static function ($parser, string $name) use (&$inside, &$buffer, &$out): void {
if ($name === 't') {
$inside = false;
} elseif ($name === 'si') {
$out[] = $buffer;
}
}
);
xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$inside, &$buffer): void {
if ($inside) {
$buffer .= $data;
}
});
try {
xml_parse($parser, preg_replace('/^\xEF\xBB\xBF/', '', $xml, 1), true);
} finally {
xml_parser_free($parser);
}
return $out;
}
}

View File

@ -1,246 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\ProductMonthlyWeightedAverageReporter;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
/**
* 检查上游完整性后,自动生成尚未计算的最新月份商品月加权平均单价。
*/
class AutoComputeProductMonthlyWeightedAverage extends Command
{
protected $signature = 'product:monthly-weighted-average:auto
{--month= : 指定月份;默认取最新已成功的进销存月份}
{--result-json= : 将执行结果写入指定 JSON 文件}';
protected $description = '上游完整后自动计算商品月加权平均单价,并保护已计算历史月份';
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$month = $this->targetMonth($connection);
if ($month === null) {
return $this->finish('blocked', null, [
'message' => '未找到已成功导入的进销存月份。',
]);
}
$lock = Cache::lock('product-monthly-weighted-average-auto:' . $month->format('Y-m'), 7200);
if (! $lock->get()) {
return $this->finish('blocked', $month, [
'message' => '同月自动计算任务正在执行。',
]);
}
try {
$state = DB::connection($connection)->table('product_monthly_weighted_average_states')
->where('report_month', $month->toDateString())
->first();
if (($state?->status ?? null) === 'computed') {
$report = $reporter->build($connection, $month);
$report['calculation_status'] = $report['status'];
$report['status'] = 'already_computed';
return $this->finish('already_computed', $month, $report, $reporter);
}
$checks = $this->checks($connection, $month);
if (in_array(false, array_column($checks, 'passed'), true)) {
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
['report_month' => $month->toDateString()],
[
'status' => 'blocked',
'last_error' => '上游数据未通过完整性检查',
'updated_at' => now(),
'created_at' => now(),
],
);
return $this->finish('blocked', $month, [
'message' => '上游数据未更新或不完整,未执行计算。',
'checks' => $checks,
]);
}
$baseExit = $this->call('product:monthly-weighted-average:base', [
'month' => $month->format('Y-m'),
'--from-previous' => true,
// 自动任务只会在尚未 computed 的月份走到这里,可安全清理失败重试留下
// 的同月基表;已 computed 的历史月份在前面的门禁直接跳过。
'--force' => true,
]);
if ($baseExit !== self::SUCCESS) {
return $this->finish('failed', $month, [
'message' => '生成期初基数失败。',
]);
}
$computeExit = $this->call('product:monthly-weighted-average:compute', [
'month' => $month->format('Y-m'),
]);
if ($computeExit !== self::SUCCESS) {
return $this->finish('failed', $month, [
'message' => '加权平均单价计算失败。',
]);
}
$report = $reporter->build($connection, $month);
$report['checks'] = $checks;
return $this->finish(
($report['status'] ?? null) === 'review_required' ? 'review_required' : 'success',
$month,
$report,
$reporter,
);
} finally {
$lock->release();
}
}
private function targetMonth(string $connection): ?CarbonImmutable
{
$requested = trim((string) $this->option('month'));
if ($requested !== '') {
return CarbonImmutable::parse($requested)->startOfMonth();
}
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
->where('status', 'succeeded')
->whereNotNull('active_generation')
->orderByDesc('source_month')
->value('source_month');
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
}
/** @return list<array{name:string,passed:bool,actual:mixed,expected:mixed}> */
private function checks(string $connection, CarbonImmutable $month): array
{
$db = DB::connection($connection);
$monthState = $db->table('inventory_report_authoritative_months')
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
->where('source_month', $month->toDateString())
->first();
$generation = $monthState?->active_generation;
$activeRows = $generation === null
? 0
: $db->table('inventory_report_authoritative_rows')
->where('generation', (string) $generation)
->count();
$expectedRows = (int) ($monthState?->source_row_count ?? 0);
$previous = $month->subMonthNoOverflow();
$previousState = $db->table('product_monthly_weighted_average_states')
->where('report_month', $previous->toDateString())
->value('status');
$previousRows = $db->table('product_monthly_weighted_average_rows')
->where('report_month', $previous->toDateString())
->count();
$inboundRows = $db->table('inventory_inbound_detail_rows')
->where('report_month', $month->toDateString())
->count();
$inboundMissingDimensions = $db->table('inventory_inbound_detail_rows')
->where('report_month', $month->toDateString())
->where(function ($query): void {
$query->whereNull('barcode')->orWhere('barcode', '')
->orWhereNull('warehouse_short_name')->orWhere('warehouse_short_name', '');
})
->count();
$inboundMissingAmounts = $db->table('inventory_inbound_detail_rows')
->where('report_month', $month->toDateString())
->whereNull('amount')
->whereNull('estimated_amount')
->count();
$productCatalogRows = $db->table('ops_jst_sku')->whereNotNull('sku_id')->count();
return [
[
'name' => '进销存月份状态成功',
'passed' => ($monthState?->status ?? null) === 'succeeded',
'actual' => $monthState?->status,
'expected' => 'succeeded',
],
[
'name' => '进销存激活明细行数完整',
'passed' => $expectedRows > 0 && $activeRows === $expectedRows,
'actual' => $activeRows,
'expected' => $expectedRows,
],
[
'name' => '上月加权平均结果已完成',
'passed' => $previousState === 'computed' && $previousRows > 0,
'actual' => ['status' => $previousState, 'rows' => $previousRows],
'expected' => ['status' => 'computed', 'rows' => '> 0'],
],
[
'name' => '入库明细已更新',
'passed' => $inboundRows > 0,
'actual' => $inboundRows,
'expected' => '> 0',
],
[
'name' => '入库商品编码与仓库简称无缺失',
'passed' => $inboundMissingDimensions === 0,
'actual' => $inboundMissingDimensions,
'expected' => 0,
],
[
'name' => '入库金额至少取金额或预估金额',
'passed' => $inboundMissingAmounts === 0,
'actual' => $inboundMissingAmounts,
'expected' => 0,
],
[
'name' => '普通商品资料已更新',
'passed' => $productCatalogRows > 0,
'actual' => $productCatalogRows,
'expected' => '> 0',
],
];
}
/**
* @param array<string,mixed> $payload
*/
private function finish(
string $status,
?CarbonImmutable $month,
array $payload,
?ProductMonthlyWeightedAverageReporter $reporter = null,
): int {
$result = ['status' => $status] + $payload;
if (! isset($result['report_month']) && $month !== null) {
$result['report_month'] = $month->toDateString();
}
$path = trim((string) $this->option('result-json'));
if ($path !== '') {
File::ensureDirectoryExists(dirname($path));
File::put(
$path,
json_encode(
$result,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
) . PHP_EOL,
);
}
if ($reporter !== null && isset($result['totals'])) {
$this->line($reporter->brief($result));
} elseif (isset($result['message'])) {
$this->error((string) $result['message']);
}
return in_array($status, ['blocked', 'failed'], true)
? self::FAILURE
: self::SUCCESS;
}
}

View File

@ -1,133 +0,0 @@
<?php
namespace App\Console\Commands;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
/**
* 固定月度顺序:先生成商品月加权平均单价,再同步商品成本表。
*/
class AutoMonthlyWeightedAverageAndProductCost extends Command
{
protected $signature = 'finance:monthly-weighted-product-cost:auto
{--month= : 指定月份;默认取最新已成功的进销存月份}
{--result-json= : 将两阶段结果写入指定 JSON 文件}';
protected $description = '先计算商品月加权平均单价,再自动同步商品成本表';
public function handle(): int
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$month = $this->targetMonth($connection);
if ($month === null) {
return $this->writeResult([
'status' => 'blocked',
'message' => '未找到已成功导入的进销存月份。',
]);
}
$weightedPath = tempnam(sys_get_temp_dir(), 'weighted-result-') . '.json';
$costPath = tempnam(sys_get_temp_dir(), 'product-cost-result-') . '.json';
try {
$weightedExit = $this->call('product:monthly-weighted-average:auto', [
'--month' => $month->format('Y-m'),
'--result-json' => $weightedPath,
]);
$weighted = $this->readJson($weightedPath);
if (
$weightedExit !== self::SUCCESS
|| in_array($weighted['status'] ?? 'failed', ['blocked', 'failed'], true)
) {
return $this->writeResult([
'status' => 'failed',
'report_month' => $month->toDateString(),
'message' => '商品月加权平均单价未完成,商品成本未执行。',
'weighted_average' => $weighted,
'product_cost' => null,
]);
}
$costExit = $this->call('store-receipt:product-cost:sync', [
'month' => $month->format('Y-m'),
'--result-json' => $costPath,
]);
$cost = $this->readJson($costPath);
$status = $costExit !== self::SUCCESS
? 'failed'
: ((int) ($cost['pending_cost_rows'] ?? 0) > 0 ? 'review_required' : 'success');
return $this->writeResult([
'status' => $status,
'report_month' => $month->toDateString(),
'message' => match ($status) {
'success' => '月加权平均单价与商品成本已按顺序完成。',
'review_required' => '月加权平均单价与商品成本已完成,存在待核验成本。',
default => '商品成本同步失败。',
},
'weighted_average' => $weighted,
'product_cost' => $cost,
]);
} finally {
@unlink($weightedPath);
@unlink($costPath);
}
}
private function targetMonth(string $connection): ?CarbonImmutable
{
$requested = trim((string) $this->option('month'));
if ($requested !== '') {
return CarbonImmutable::parse($requested)->startOfMonth();
}
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
->where('status', 'succeeded')
->whereNotNull('active_generation')
->orderByDesc('source_month')
->value('source_month');
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
}
/** @return array<string,mixed> */
private function readJson(string $path): array
{
if (! is_file($path)) {
return ['status' => 'failed', 'message' => '子任务未生成结果文件。'];
}
$decoded = json_decode((string) file_get_contents($path), true);
return is_array($decoded)
? $decoded
: ['status' => 'failed', 'message' => '子任务结果文件无法解析。'];
}
/** @param array<string,mixed> $result */
private function writeResult(array $result): int
{
$path = trim((string) $this->option('result-json'));
if ($path !== '') {
File::ensureDirectoryExists(dirname($path));
File::put(
$path,
json_encode(
$result,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
) . PHP_EOL,
);
}
if (isset($result['message'])) {
$this->line((string) $result['message']);
}
return in_array($result['status'] ?? 'failed', ['success', 'review_required'], true)
? self::SUCCESS
: self::FAILURE;
}
}

View File

@ -1,29 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\WorkOrderWaybillLookup;
class BackfillWorkOrderWaybillLookup extends Command
{
protected $signature = 'business-mirror:index-work-order-waybills {--batch=500} {--max-batches=2} {--pause-ms=50}';
protected $description = '按稳定游标建立查件运单小索引;生产须使用 run-finance-batch.sh 执行';
public function handle(WorkOrderWaybillLookup $lookup): int
{
$lock = Cache::store('upstream_shared')->lock('work-order-waybill-lookup:backfill', 3600);
if (! $lock->get()) {
$this->info('已有运单索引任务运行,本轮跳过。');
return self::SUCCESS;
}
try {
$result = $lookup->backfill((int) $this->option('batch'), max(0, (int) $this->option('max-batches')), max(0, (int) $this->option('pause-ms')));
$this->info(json_encode($result));
return self::SUCCESS;
} finally {
$lock->release();
}
}
}

View File

@ -2,45 +2,26 @@
namespace App\Console\Commands;
use App\Business\ProductMonthlyWeightedAverageReporter;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
/**
* 按取数逻辑计算某个月的更改价格、定价状态、采购单价、期初金额、入库金额和加权平均单价。
*/
class ComputeProductMonthlyWeightedAverage extends Command
{
protected $signature = 'product:monthly-weighted-average:compute
{month : 报表月份,例如 2026-07}
{--force : 覆盖已经计算完成的同月结果}
{--result-json= : 将结果简报写入指定 JSON 文件}';
protected $signature = 'product:monthly-weighted-average:compute {month : 报表月份,例如 2026-07}';
protected $description = '按取数逻辑计算商品月加权平均单价';
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
public function handle(): int
{
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$table = 'product_monthly_weighted_average_rows';
$deadline = $month->addMonthNoOverflow()->day(2)->startOfDay();
$stateStatus = DB::connection($connection)
->table('product_monthly_weighted_average_states')
->where('report_month', $month->toDateString())
->value('status');
$computedRows = DB::connection($connection)->table($table)
->where('report_month', $month->toDateString())
->whereNotNull('computed_at')
->count();
if (! $this->option('force') && ($stateStatus === 'computed' || $computedRows > 0)) {
$this->error($month->format('Y-m') . ' 已经计算完成,历史月份不自动覆盖;如需人工纠正请显式使用 --force。');
return self::FAILURE;
}
$query = DB::connection($connection)->table($table)->where('report_month', $month->toDateString());
$codes = (clone $query)->distinct()->pluck('product_code')->map(fn ($code) => trim((string) $code))->unique()->values()->all();
$this->info('待计算 SKU:' . count($codes));
@ -106,21 +87,7 @@ class ComputeProductMonthlyWeightedAverage extends Command
['status' => 'computed', 'computed_rows' => $computed, 'last_computed_at' => $now, 'updated_at' => $now, 'created_at' => $now],
);
$report = $reporter->build($connection, $month);
$json = json_encode($report, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if (is_string($json)) {
$resultPath = trim((string) $this->option('result-json'));
if ($resultPath !== '') {
File::ensureDirectoryExists(dirname($resultPath));
File::put($resultPath, $json . PHP_EOL);
}
}
$this->info($month->format('Y-m') . " 计算完成:{$computed} 行");
$this->line($reporter->brief($report));
if (($report['status'] ?? null) === 'review_required') {
$this->warn('存在待复核差异;结果已生成,但不会视为完全无异常。');
}
return self::SUCCESS;
}

View File

@ -1,164 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSchemaManager;
use Throwable;
/**
* 让登记过的数据集自动跟随上游新增字段。
*
* 上游视图/表新增列时,数据字典(upstream_schema_columns)会先刷新出来,这里再把新列
* 补进数据集定义、建好镜像列;下一次常规同步就会把数据带进来,页面也会自动多出这一列。
* 只做"新增":不删除、不改写已有字段,敏感字段名一律跳过。
*/
class FollowUpstreamColumns extends Command
{
protected $signature = 'business-mirror:follow-upstream-columns
{--dataset= : 只处理指定数据集}';
protected $description = '把上游新增字段补进登记过的镜像数据集(页面字段自动跟随)';
private const DENIED_KEYWORDS = ['password', 'passwd', 'secret', 'token', 'cipher', 'credential', 'private'];
public function handle(
BusinessMirrorDatasetRegistry $datasets,
BusinessMirrorSchemaManager $schemas,
): int {
$keys = array_values(array_filter(array_map(
static fn (mixed $key): string => trim((string) $key),
(array) ($this->option('dataset') ?: array_values((array) config('upstream_mirrors.auto_follow_columns', []))),
), static fn (string $key): bool => $key !== ''));
if ($keys === []) {
$this->info('没有登记需要自动跟随上游字段的数据集。');
return self::SUCCESS;
}
// 先刷新数据字典,否则新字段根本不在目录里。该命令自带 24 小时新鲜度判断,
// 已经同步过会直接跳过。
foreach (array_unique($this->sourcesOf($keys)) as $source) {
try {
// --due:数据字典新鲜(24 小时内同步过)就不再重复扫上游。
Artisan::call('upstream:schema:sync', ['source' => $source, '--due' => true]);
} catch (Throwable $exception) {
report($exception);
$this->warn($source .':数据字典刷新失败,本轮跳过新字段检测。');
}
}
$followed = 0;
foreach ($keys as $key) {
try {
$added = $this->follow($key, $datasets, $schemas);
if ($added !== []) {
$followed += count($added);
$this->info($key . ':新增 ' . implode('、', $added) . ' 并已建列,下次同步即带回数据。');
Log::info('业务镜像自动跟随上游新增字段。', ['dataset' => $key, 'columns' => $added]);
}
} catch (Throwable $exception) {
report($exception);
$this->error($key . ':跟随失败 —— ' . $exception->getMessage());
}
}
$this->info($followed > 0 ? sprintf('共补入 %d 个新字段。', $followed) : '没有发现上游新增字段。');
return self::SUCCESS;
}
/** @param list<string> $keys @return list<string> */
private function sourcesOf(array $keys): array
{
$sources = [];
foreach (DB::table('business_mirror_datasets')->whereIn('dataset_key', $keys)->get() as $row) {
$definition = json_decode((string) $row->definition, true) ?: [];
$source = trim((string) ($definition['source_key'] ?? ''));
if ($source !== '') {
$sources[] = $source;
}
}
return $sources;
}
/**
* @return list<string> 本次补入的字段
*/
private function follow(string $key, BusinessMirrorDatasetRegistry $datasets, BusinessMirrorSchemaManager $schemas): array
{
$row = DB::table('business_mirror_datasets')->where('dataset_key', $key)->first();
if ($row === null) {
return [];
}
$definition = json_decode((string) $row->definition, true) ?: [];
$source = trim((string) ($definition['source_key'] ?? ''));
$schema = trim((string) ($definition['schema'] ?? ''));
$object = trim((string) ($definition['object'] ?? ''));
$current = array_values(array_filter(array_map(
static fn (mixed $column): string => trim((string) $column),
(array) ($definition['columns'] ?? []),
), static fn (string $column): bool => $column !== ''));
if ($source === '' || $schema === '' || $object === '' || $current === []) {
return [];
}
$upstream = DB::table('upstream_schema_columns')
->where('source_key', $source)
->whereRaw('LOWER(schema_name) = ?', [strtolower($schema)])
->whereRaw('LOWER(object_name) = ?', [strtolower($object)])
->orderBy('ordinal_position')
->pluck('column_name')
->map(static fn (mixed $name): string => (string) $name)
->all();
$added = [];
foreach ($upstream as $column) {
if (in_array($column, $current, true) || str_starts_with($column, '_mirror') || $this->isSensitive($column)) {
continue;
}
$added[] = $column;
}
if ($added === []) {
return [];
}
$definition['columns'] = array_values(array_merge($current, $added));
// 记下"跟随进来的字段":页面据此自动补列,不会把历史上刻意没展示的字段带出来。
$definition['followed_columns'] = array_values(array_unique(array_merge(
(array) ($definition['followed_columns'] ?? []),
$added,
)));
DB::table('business_mirror_datasets')->where('dataset_key', $key)->update([
'definition' => json_encode($definition, JSON_THROW_ON_ERROR),
'updated_at' => now(),
]);
// 立刻建列,下一次同步就能写入;页面在数据到达前显示为空即可。
$schemas->ensure($datasets->get($key));
return $added;
}
private function isSensitive(string $column): bool
{
$name = strtolower($column);
foreach (self::DENIED_KEYWORDS as $keyword) {
if (str_contains($name, $keyword)) {
return true;
}
}
return false;
}
}

View File

@ -1,32 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Common\Company;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierImporter;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierStore;
class ImportAccessoryReshipCourierConfigs extends Command
{
protected $signature = 'accessory-reship:import-courier-configs {file} {--company= : 导入所属公司ID} {--dry-run : 仅检查文件及输出条数,不写入}';
protected $description = '导入用户提供的存量快递补发扣款比例配置(重复项跳过、冲突整批回滚)';
public function handle(AccessoryReshipCourierImporter $importer, AccessoryReshipCourierStore $store): int
{
$companyId = (int) $this->option('company');
if ($companyId <= 0 || !Company::query()->where('id', $companyId)->exists()) {
$this->error('请指定有效的 --company,公司配置不会跨公司写入。');
return self::FAILURE;
}
$path = $this->argument('file');
$rows = $importer->read($path);
if ($this->option('dry-run')) {
$this->info('文件校验通过,共' . count($rows) . '条;未写入。');
return self::SUCCESS;
}
$result = $store->import($companyId, $rows, '用户授权存量配置导入', hash_file('sha256', $path));
$this->info('导入完成:新增' . $result['added'] . '条,重复跳过' . $result['skipped'] . '条。');
return self::SUCCESS;
}
}

View File

@ -1,32 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\AccountMappingStore;
/**
* 把科目映射表的旧文件存储(storage/app/account-mapping/rules.json / events.json)
* 导入共享系统数据库(ec_account_mapping_rules / ec_account_mapping_events)。
*
* 幂等:以文件为准覆盖数据库内容,可重复执行。
*/
class ImportAccountMappingFiles extends Command
{
protected $signature = 'account-mapping:import-files {--events : 一并导入操作记录}';
protected $description = '把科目映射表的旧文件存储导入共享数据库';
public function handle(AccountMappingStore $store): int
{
$result = $store->syncFromFiles((bool) $this->option('events'));
$this->info(
'已导入规则 ' . $result['rules'] . ' 条'
. ($result['events'] > 0 ? ',操作记录 ' . $result['events'] . ' 条' : '')
. '。'
);
return self::SUCCESS;
}
}

View File

@ -1,326 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository;
use XMLReader;
use ZipArchive;
/**
* 导入聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」Excel。
*
* 该报表已按商品编码(组合装拆成组件)给出每行和成本,导入后订单售后费用明细页面
* 在覆盖的店铺/日期范围内直接按这些明细行展示。
*/
class ImportAfterSalesCostRows extends Command
{
protected $signature = 'order-data:import-after-sales-cost {file : Excel 文件路径} {--chunk=500 : 每批写入行数} {--dry-run : 只解析不入库}';
protected $description = '导入聚水潭「订单售后费用数据」Excel 到订单售后费用明细';
public function handle(): int
{
$path = (string) $this->argument('file');
if (! is_file($path)) {
$this->error('文件不存在:' . $path);
return self::FAILURE;
}
$headers = (array) config('order_after_sales_cost.headers', []);
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
if ($headers === []) {
$this->error('缺少表头映射:config/order_after_sales_cost.php');
return self::FAILURE;
}
$this->info('流式读取文件:' . $path);
$sheetPath = $this->firstSheetPath($path);
if ($sheetPath === null) {
$this->error('不是有效的 xlsx(缺少工作表)。');
return self::FAILURE;
}
try {
$sharedStrings = $this->readSharedStrings($path);
} catch (\Throwable $exception) {
$this->error('读取共享字符串失败:' . $exception->getMessage());
return self::FAILURE;
}
$columns = [];
$first = true;
$chunkSize = max((int) $this->option('chunk'), 100);
$dryRun = (bool) $this->option('dry-run');
$buffer = [];
$total = 0;
$written = 0;
$skipped = 0;
$shops = [];
$minDate = null;
$maxDate = null;
$importedAt = now()->toDateTimeString();
$reader = new XMLReader();
if (! $reader->open('zip://' . $path . '#' . $sheetPath)) {
$this->error('无法打开工作表:' . $sheetPath);
return self::FAILURE;
}
while ($reader->read()) {
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
continue;
}
$cells = $this->readRow($reader, $sharedStrings);
if ($first) {
$first = false;
foreach ($cells as $index => $header) {
$header = self::normalizeHeader((string) $header);
if ($header !== '' && isset($headers[$header])) {
$columns[$index] = $headers[$header];
}
}
if ($columns === []) {
$reader->close();
$this->error('表头和配置的映射对不上,请检查文件或 config/order_after_sales_cost.php');
return self::FAILURE;
}
continue;
}
$total++;
$payload = [];
foreach ($columns as $index => $key) {
$value = $cells[$index] ?? null;
if (is_string($value)) {
$value = trim($value);
}
if ($value === '' || $value === null) {
continue;
}
if (isset($numberKeys[$key])) {
$value = is_numeric($value) ? (float) $value : 0.0;
} elseif (self::isDateKey($key)) {
$value = self::dateValue($value);
}
$payload[$key] = $value;
}
$shop = trim((string) ($payload['shop_name'] ?? ''));
if ($shop === '' || empty($payload['product_code'])) {
$skipped++;
continue;
}
$buffer[] = AfterSalesCostRowRepository::toRecord($payload, $importedAt);
$shops[$shop] = true;
$orderTime = $payload['order_time'] ?? null;
if (is_string($orderTime) && $orderTime !== '') {
$minDate = $minDate === null || $orderTime < $minDate ? $orderTime : $minDate;
$maxDate = $maxDate === null || $orderTime > $maxDate ? $orderTime : $maxDate;
}
if (! $dryRun && count($buffer) >= $chunkSize) {
$written += $this->flush($buffer);
$buffer = [];
$this->output->write("\r写入 " . $written . ' 行');
}
if ($dryRun && $buffer !== []) {
$buffer = [];
}
}
$reader->close();
if (! $dryRun && $buffer !== []) {
$written += $this->flush($buffer);
}
$this->newLine(2);
$this->info(sprintf(
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;订单时间 %s ~ %s;峰值内存 %.1f MB',
$total,
$written,
$skipped,
count($shops),
$minDate ?? '-',
$maxDate ?? '-',
memory_get_peak_usage(true) / 1048576,
));
return self::SUCCESS;
}
private function firstSheetPath(string $path): ?string
{
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
return null;
}
$sheet = null;
if ($zip->locateName('xl/worksheets/sheet1.xml') !== false) {
$sheet = 'xl/worksheets/sheet1.xml';
} else {
for ($i = 1; $i <= 30; $i++) {
$name = 'xl/worksheets/sheet' . $i . '.xml';
if ($zip->locateName($name) !== false) {
$sheet = $name;
break;
}
}
}
$zip->close();
return $sheet;
}
/** @return array<int,string> */
private function readSharedStrings(string $path): array
{
$strings = [];
$reader = new XMLReader();
if (! $reader->open('zip://' . $path . '#xl/sharedStrings.xml')) {
return $strings;
}
while ($reader->read()) {
if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'si') {
$xml = $reader->readOuterXml();
$strings[] = html_entity_decode(strip_tags($xml), ENT_QUOTES | ENT_XML1, 'UTF-8');
}
}
$reader->close();
return $strings;
}
/** @param array<int,string> $shared @return array<int,mixed> */
private function readRow(XMLReader $reader, array $shared): array
{
$cells = [];
$xml = $reader->readOuterXml();
if ($xml === '') {
return $cells;
}
$row = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
if ($row === false) {
return $cells;
}
foreach ($row->c as $cell) {
$index = self::columnIndex((string) $cell['r']);
$type = (string) $cell['t'];
if ($type === 's') {
$value = $shared[(int) $cell->v] ?? '';
} elseif ($type === 'inlineStr') {
$value = (string) $cell->is->t;
} else {
$value = (string) $cell->v;
}
$cells[$index] = $value;
}
return $cells;
}
private static function columnIndex(string $reference): int
{
$letters = preg_replace('/[^A-Z]/', '', strtoupper($reference)) ?? '';
$index = 0;
foreach (str_split($letters) as $letter) {
$index = $index * 26 + (ord($letter) - 64);
}
return $index - 1;
}
/** @param list<array<string,mixed>> $rows */
private function flush(array $rows): int
{
DB::table('ops_after_sales_cost_rows')->upsert(
$rows,
['row_key'],
[
'shop_name', 'shop_code', 'online_order_no', 'internal_order_no', 'product_code',
'order_time', 'product_quantity', 'product_amount', 'product_cost', 'cost_source',
'combine_type', 'payload', 'imported_at', 'updated_at',
],
);
return count($rows);
}
private static function normalizeHeader(string $header): string
{
$header = str_replace(["\u{3000}", "\u{00A0}"], ' ', $header);
$header = preg_replace('/\s+/u', ' ', $header) ?? $header;
return trim($header);
}
private static function isDateKey(string $key): bool
{
return in_array($key, [
'pay_time', 'order_complete_time', 'send_time', 'order_time', 'registration_time',
'confirmation_time', 'warehouse_entry_time',
], true);
}
private static function dateValue(mixed $value): ?string
{
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
}
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 80000) {
// Excel 序列日期(1899-12-30 起算),按本地时间格式化。
$timestamp = ((float) $value - 25569) * 86400;
return gmdate('Y-m-d H:i:s', (int) $timestamp);
}
$timestamp = strtotime((string) $value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
}

View File

@ -1,41 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\CourierFreightImporter;
use Illuminate\Console\Command;
use Throwable;
class ImportCourierFreight extends Command
{
protected $signature = 'courier-freight:import
{file : xlsx / csv 文件路径}
{--company=1 : 公司 ID}
{--replace : 先清空该公司已有的快递运费明细}';
protected $description = '导入「快递运费」数据(财务经营报表 › 快递运费)';
public function handle(CourierFreightImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('file'),
(int) $this->option('company'),
(bool) $this->option('replace'),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$sources = $result['sources'];
$result['sources'] = count($sources);
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
foreach ($sources as $name => $count) {
$this->line(sprintf(' %s:%d 行', $name === '' ? '(空来源)' : $name, $count));
}
return self::SUCCESS;
}
}

View File

@ -1,280 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Common\Company;
use App\Support\CourierQuoteStructure;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* 把整理好的结构化计费规则(标准计费规则_导入用.json)写入现有报价版本,供运费核算使用。
* 只写 billing_structure / billing_rows 两列,原始报价单元格、执行日期、状态都不动。
*/
class ImportCourierQuoteBillingRules extends Command
{
protected $signature = 'courier-quotes:import-billing-rules {file : 标准计费规则 JSON}
{--company= : 报价所属公司ID}
{--dry-run : 只校验并输出结果,不写入}
{--force : 覆盖已有且内容不同的计费规则}';
protected $description = '把存量报价的结构化计费规则批量写入报价版本(按版本号匹配,整批事务)';
private const MODES = ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then', CourierQuoteStructure::SEGMENTS];
public function handle(): int
{
$companyId = (int) $this->option('company');
if ($companyId <= 0 || ! Company::query()->where('id', $companyId)->exists()) {
$this->error('请指定有效的 --company。');
return self::FAILURE;
}
$payload = json_decode((string) @file_get_contents((string) $this->argument('file')), true);
$items = is_array($payload) ? ($payload['versions'] ?? null) : null;
if (! is_array($items) || $items === []) {
$this->error('文件不是有效的计费规则 JSON(缺少 versions)。');
return self::FAILURE;
}
$plan = [];
$report = [];
$seen = [];
foreach ($items as $item) {
if (! is_array($item)) {
$report[] = ['', '', '错误', '版本条目必须是对象'];
continue;
}
$code = (string) ($item['version_id'] ?? '');
if (isset($seen[$code])) {
$report[] = [$code, '', '错误', '同一文件中的版本号不能重复'];
continue;
}
$seen[$code] = true;
[$status, $note, $data] = $this->prepare($companyId, $item);
$report[] = [$code, (string) ($item['company_name'] ?? ''), $status, $note];
if ($data !== null) {
$plan[] = $data;
}
}
$this->table(['版本', '快递公司', '结果', '说明'], $report);
$counts = array_count_values(array_column($report, 2));
$summary = collect($counts)->map(fn ($n, $k) => "{$k} {$n}")->implode(',');
if (isset($counts['错误'])) {
$this->error('存在错误,整批未写入:' . $summary);
return self::FAILURE;
}
if ($this->option('dry-run')) {
$this->info('校验通过,未写入(dry-run):' . $summary);
return self::SUCCESS;
}
try {
DB::transaction(function () use ($plan): void {
foreach ($plan as [$version, $structure, $rows]) {
$current = DB::table($version->getTable())->where('id', $version->id)->lockForUpdate()->first();
if (! $current || $current->company_name !== $version->company_name
|| json_decode($current->billing_structure ?? 'null', true) != $version->billing_structure
|| json_decode($current->billing_rows ?? 'null', true) != $version->billing_rows) {
throw new RuntimeException('报价在校验后被其他操作修改,整批未写入,请重新校验。');
}
$version->forceFill(['billing_structure' => $structure, 'billing_rows' => $rows])->save();
}
});
} catch (RuntimeException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info('导入完成:' . $summary);
return self::SUCCESS;
}
/**
* @param array<string,mixed> $item
* @return array{0:string,1:string,2:?array}
*/
private function prepare(int $companyId, array $item): array
{
$code = (string) ($item['version_id'] ?? '');
$version = CourierQuoteVersion::query()->allCompanies()->where('company_id', $companyId)->where('version_code', $code)->first();
if ($version === null) {
return ['错误', '系统中找不到该版本号', null];
}
if (trim((string) $version->company_name) !== trim((string) ($item['company_name'] ?? ''))) {
return ['错误', '快递名称不一致:系统为「' . $version->company_name . '」', null];
}
if (($item['conversion'] ?? '') === 'manual') {
return ['跳过', '特殊计费(' . ($item['manual_reason'] ?? '按体积/条码') . '),需人工核算', null];
}
if (($error = $this->inputError($item)) !== null) {
return ['错误', $error, null];
}
$source = (array) ($item['structure'] ?? []);
$segments = ($item['conversion'] ?? '') === 'needs_segment_mode';
$mode = $segments ? CourierQuoteStructure::SEGMENTS : (string) ($source['mode'] ?? '');
if (! in_array($mode, self::MODES, true)) {
return ['错误', '不支持的计费方式:' . $mode, null];
}
$params = (array) ($item['params'] ?? []);
$perTicket = 0.0;
foreach ((array) ($params['surcharges'] ?? []) as $surcharge) {
if (($surcharge['condition'] ?? '') === '每票' && is_numeric($surcharge['amount'] ?? null)) {
$perTicket += (float) $surcharge['amount'];
}
}
$structure = [
'mode' => $mode,
'flat_bands' => $segments ? [] : $this->numbers($source['flat_bands'] ?? []),
'then_mode' => $segments ? '' : (string) ($source['then_mode'] ?? ''),
'first_weight' => ! $segments && is_numeric($source['first_weight'] ?? null) ? (float) $source['first_weight'] : null,
'rate_tiers' => $segments ? [] : $this->numbers($source['rate_tiers'] ?? []),
'rounding' => in_array($source['rounding'] ?? 'none', array_keys(CourierQuoteStructure::ROUNDING), true) ? (string) ($source['rounding'] ?? 'none') : 'none',
'volumetric_divisor' => is_numeric($params['volumetric_divisor'] ?? null) ? (int) $params['volumetric_divisor'] : null,
'weight_limit' => is_numeric($params['weight_limit_kg'] ?? null) ? (float) $params['weight_limit_kg'] : null,
'min_weight' => is_numeric($source['min_weight'] ?? $params['min_weight_kg'] ?? null) ? (float) ($source['min_weight'] ?? $params['min_weight_kg']) : null,
'per_ticket_surcharge' => $perTicket > 0 ? $perTicket : null,
'notes' => array_values(array_map('strval', (array) ($params['notes'] ?? []))),
'imported_from' => 'stock-quote-billing-import',
];
$rows = [];
foreach ((array) ($segments ? ($item['segments_rows'] ?? []) : ($item['rows'] ?? [])) as $row) {
$region = trim((string) ($row['region'] ?? ''));
if ($region === '') {
return ['错误', '存在没有地区名的行', null];
}
if ($segments) {
$list = [];
foreach ((array) ($row['segments'] ?? []) as $s) {
$list[] = array_filter([
'lo' => (float) ($s['lo'] ?? 0),
'hi' => is_numeric($s['hi'] ?? null) ? (float) $s['hi'] : null,
'type' => (string) ($s['type'] ?? ''),
'price' => $this->number($s['price'] ?? null),
'base' => $this->number($s['base'] ?? null),
'rate' => $this->number($s['rate'] ?? null),
'fw' => $this->number($s['fw'] ?? null),
], static fn ($v, $k) => $v !== null || $k === 'hi', ARRAY_FILTER_USE_BOTH);
}
$rows[] = ['region' => $region, 'segments' => $list];
} else {
$rows[] = [
'region' => $region,
'flat' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['flat'] ?? []))),
'base' => $this->number($row['base'] ?? null),
'rates' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['rates'] ?? []))),
];
}
}
if ($rows === []) {
return ['错误', '没有任何地区价格', null];
}
$existing = $version->billing_structure;
$note = CourierQuoteStructure::describe($structure) . ',' . count($rows) . ' 个地区';
if (is_array($existing) && $existing !== []) {
if ($existing == $structure && $version->billing_rows == $rows) {
return ['跳过', '已导入且内容相同', null];
}
if (! $this->option('force')) {
return ['错误', '已有不同的计费规则,如需覆盖请加 --force', null];
}
return ['覆盖', $note, [$version, $structure, $rows]];
}
return ['写入', $note, [$version, $structure, $rows]];
}
private function number(mixed $value): ?float
{
return is_numeric($value) ? round((float) $value, 4) : null;
}
/** 金额导入拒绝负数、歧义分段和不支持的附加条件,不静默改写计费含义。 */
private function inputError(array $item): ?string
{
$structure = $item['structure'] ?? null;
$params = $item['params'] ?? [];
if (! is_array($structure) || ! is_array($params)) {
return 'structure 和 params 必须是对象';
}
if (! array_key_exists($structure['rounding'] ?? 'none', CourierQuoteStructure::ROUNDING)) {
return '不支持的重量取整方式';
}
$valid = static fn ($v): bool => is_numeric($v) && is_finite((float) $v) && (float) $v >= 0;
foreach ([$params['volumetric_divisor'] ?? null, $params['weight_limit_kg'] ?? null,
$structure['min_weight'] ?? $params['min_weight_kg'] ?? null, $structure['first_weight'] ?? null] as $v) {
if ($v !== null && ! $valid($v)) return '重量、体积系数必须是非负有限数字';
}
if (isset($params['volumetric_divisor']) && (float) $params['volumetric_divisor'] <= 0) return '体积系数必须大于0';
$minimum = (float) ($structure['min_weight'] ?? $params['min_weight_kg'] ?? 0);
if (($params['weight_limit_kg'] ?? 0) > 0 && $minimum > $params['weight_limit_kg']) return '起算重量不能大于限重';
foreach (['flat_bands', 'rate_tiers'] as $field) {
$previous = 0;
if (! is_array($structure[$field] ?? [])) return '重量分段必须是数组';
foreach ($structure[$field] ?? [] as $edge) {
if (! $valid($edge) || (float) $edge <= $previous) return '重量节点必须为严格递增的正数';
$previous = (float) $edge;
}
}
$mode = ($item['conversion'] ?? '') === 'needs_segment_mode' ? CourierQuoteStructure::SEGMENTS : ($structure['mode'] ?? '');
if (in_array($mode, ['flat_bands', 'flat_then'], true) && empty($structure['flat_bands'])) return '缺少一口价重量节点';
if ($mode === 'flat_then' && ! in_array($structure['then_mode'] ?? '', ['waybill_rate', 'first_additional'], true)) return '不支持的超重计费方式';
if (CourierQuoteStructure::usesFirstWeight($mode, $structure['then_mode'] ?? '') && ! (($structure['first_weight'] ?? 0) > 0)) return '首重必须大于0';
if ($mode === 'flat_then' && ! empty($structure['rate_tiers']) && min($structure['rate_tiers']) <= max($structure['flat_bands'])) return '超重费率节点必须大于一口价最后节点';
if (! is_array($params['surcharges'] ?? [])) return '附加费必须是数组';
foreach ($params['surcharges'] ?? [] as $fee) {
if (! is_array($fee) || ($fee['condition'] ?? '') !== '每票' || ! $valid($fee['amount'] ?? null)) return '附加费条件不支持自动核算,需人工核对';
}
$rows = $item[$mode === CourierQuoteStructure::SEGMENTS ? 'segments_rows' : 'rows'] ?? null;
if (! is_array($rows) || $rows === []) return '没有任何地区价格';
$regions = [];
foreach ($rows as $row) {
if (! is_array($row) || ! is_string($row['region'] ?? null) || trim($row['region']) === '') return '地区行格式不正确';
$region = mb_strtolower(preg_replace('/\s+/u', '', $row['region']));
if (isset($regions[$region])) return '存在重复地区行';
$regions[$region] = true;
if ($mode === CourierQuoteStructure::SEGMENTS) {
if (! is_array($row['segments'] ?? null) || $row['segments'] === []) return '缺少地区计费分段';
$previous = 0.0;
foreach ($row['segments'] as $segment) {
if (! is_array($segment) || ! $valid($segment['lo'] ?? null)
|| (float) $segment['lo'] < $previous || ! in_array($segment['type'] ?? '', ['flat', 'waybill', 'first_add'], true)) return '分段类型不支持或存在重叠';
$hi = $segment['hi'] ?? null;
if ($hi !== null && (! $valid($hi) || (float) $hi <= (float) $segment['lo'])) return '分段上限必须大于下限';
$previous = $hi === null ? INF : (float) $hi;
foreach (['price', 'base', 'rate', 'fw'] as $key) {
if (isset($segment[$key]) && ! $valid($segment[$key])) return '分段金额和重量必须是非负有限数字';
}
if ($segment['type'] === 'first_add' && ! $valid($segment['fw'] ?? null)) return '分段首重缺失';
}
} else {
if (! is_array($row['flat'] ?? []) || ! is_array($row['rates'] ?? [])) return '价格必须是数组';
if (count($row['flat'] ?? []) !== count($structure['flat_bands'] ?? [])
|| (CourierQuoteStructure::usesRates($mode) && count($row['rates'] ?? []) !== count($structure['rate_tiers'] ?? []) + 1)) return '价格列数与重量节点不匹配';
foreach (array_merge($row['flat'] ?? [], $row['rates'] ?? [], [$row['base'] ?? null]) as $v) {
if ($v !== null && ! $valid($v)) return '价格必须是非负有限数字或null(不发)';
}
}
}
return null;
}
/** @return list<float> */
private function numbers(mixed $values): array
{
return array_values(array_map('floatval', array_filter((array) $values, 'is_numeric')));
}
}

View File

@ -1,49 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerBaseImporter;
use Throwable;
class ImportFinanceLedgerBase extends Command
{
protected $signature = 'finance-ledger:import-base
{path : 《银行当前余额-*.xlsx》路径}
{--opening-date= : 期初日期,默认读取配置}
{--recalculate : 导入后立即重算每日流水}';
protected $description = '导入财务台账的账户、店铺、公司、期初余额(全量覆盖)';
public function handle(FinanceLedgerBaseImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('path'),
$this->option('opening-date') ?: null,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '数量/金额'], [
['账户', $result['accounts']],
['店铺', $result['shops']],
['公司', $result['companies']],
['期初账户', $result['opening_rows']],
['期初合计', number_format($result['opening_sum'], 2)],
['账户余额合计', number_format($result['account_balance_sum'], 2)],
['期初日期', $result['opening_date']],
]);
if ($this->option('recalculate')) {
$this->call('finance-ledger:recalculate');
}
$this->info('财务台账基础数据导入完成。');
return self::SUCCESS;
}
}

View File

@ -1,42 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerSnapshotImporter;
use Throwable;
class ImportFinanceLedgerSnapshot extends Command
{
protected $signature = 'finance-ledger:import-snapshot
{path : 已核对的 ledger_raw.json 路径}
{--no-recalculate : 只导入明细,不重算流水}';
protected $description = '导入财务台账动账明细快照(本地开发或首次建账使用)';
public function handle(FinanceLedgerSnapshotImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('path'),
! $this->option('no-recalculate'),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['明细行数', $result['rows']],
['收入合计', number_format($result['income'], 2)],
['支出合计', number_format($result['expense'], 2)],
['日期范围', $result['from'] . ' ~ ' . $result['to']],
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
['期末余额合计', isset($result['recalculated']) ? number_format($result['recalculated']['balance_sum'], 2) : '-'],
]);
$this->info('财务台账动账明细导入完成。');
return self::SUCCESS;
}
}

View File

@ -2,9 +2,11 @@
namespace App\Console\Commands;
use App\Business\InventoryInboundDetailImporter;
use App\Models\Business\InventoryWarehouseNameMapping;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Throwable;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* 进销存报表 → 入库明细数据:按原始 Excel 原样导入,仓库简称用仓库名称映射表按「仓库」唯一匹配。
@ -15,28 +17,194 @@ class ImportInventoryInboundDetail extends Command
protected $description = '导入进销存报表的入库明细数据';
public function handle(InventoryInboundDetailImporter $importer): int
public function handle(): int
{
$file = (string) $this->argument('file');
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$table = 'inventory_inbound_detail_rows';
$forcedMonth = trim((string) ($this->option('month') ?? ''));
$now = now();
try {
$result = $importer->import($file, $forcedMonth);
$this->info(sprintf(
'导入完成:%d 行,仓库简称匹配上 %d 行,跳过 %d 行;库中该表共 %d 行',
$result['imported'],
$result['mapped'],
$result['skipped'],
$result['total'],
));
$maps = $this->shortNameMap();
$this->info('仓库名称映射:仓库名 ' . count($maps['warehouse']) . ' 个、仓储方 ' . count($maps['provider']) . ' 个可唯一匹配');
return self::SUCCESS;
} catch (Throwable $exception) {
report($exception);
$this->error('入库明细导入失败:' . $exception->getMessage());
$this->warn('已保留上一版有效数据。');
// 原样导入:以本次文件为准整体重建,不做去重、不做加工。
DB::connection($connection)->table($table)->delete();
return self::FAILURE;
$reader = IOFactory::createReaderForFile($file);
$reader->setReadDataOnly(true);
$sheet = $reader->load($file)->getSheet(0);
$batch = [];
$imported = 0;
$mapped = 0;
$skipped = 0;
foreach ($sheet->getRowIterator(2) as $row) {
$rowNumber = $row->getRowIndex();
$cells = [];
foreach ($row->getCellIterator('A', 'U') as $cell) {
$cells[] = $cell->getFormattedValue();
}
$warehouse = trim((string) ($cells[14] ?? ''));
$createdRaw = trim((string) ($cells[16] ?? ''));
if ($warehouse === '' && $createdRaw === '') {
$skipped++;
continue;
}
$month = $forcedMonth !== '' ? $forcedMonth : $this->monthOf($createdRaw);
if ($month === '') {
$skipped++;
continue;
}
// 一般用"仓库"匹配仓库简称;含"代发仓"字样的按"仓储方"匹配。
$shortName = '';
if ($warehouse !== '') {
$shortName = str_contains($warehouse, '代发仓')
? ($maps['provider'][$warehouse] ?? $maps['warehouse'][$warehouse] ?? '')
: ($maps['warehouse'][$warehouse] ?? '');
}
if ($shortName !== '') {
$mapped++;
}
$batch[] = [
'report_month' => $month . '-01',
// 唯一键按 Excel 行号生成,保留原表每一行(含重复行)。
'row_key' => sha1($month . '|' . $rowNumber),
'source_no' => $this->text($cells[0] ?? ''),
'document_no' => $this->text($cells[1] ?? ''),
'created_serial' => $this->text($cells[2] ?? ''),
'supplier' => $this->text($cells[3] ?? ''),
'item_no' => $this->text($cells[4] ?? ''),
'barcode' => $this->text($cells[5] ?? ''),
'spec_name' => $this->text($cells[6] ?? ''),
'quantity' => $this->number($cells[7] ?? ''),
'unit_price' => $this->number($cells[8] ?? ''),
'amount' => $this->number($cells[9] ?? ''),
'estimated_unit_price' => $this->number($cells[10] ?? ''),
'estimated_amount' => $this->number($cells[11] ?? ''),
'supplier_barcode' => $this->text($cells[12] ?? ''),
'remark' => $this->text($cells[13] ?? ''),
'warehouse' => $warehouse !== '' ? $warehouse : null,
'warehouse_short_name' => $shortName !== '' ? $shortName : null,
'created_at_raw' => $this->moment($createdRaw),
'express_name' => $this->text($cells[17] ?? ''),
'express_no' => $this->text($cells[18] ?? ''),
'not_sent' => $this->text($cells[19] ?? ''),
'owner_name' => $this->text($cells[20] ?? ''),
'imported_at' => $now,
];
$imported++;
if (count($batch) >= 1000) {
DB::connection($connection)->table($table)->insertOrIgnore($batch);
$this->info('已写入 ' . $imported . ' 行');
$batch = [];
}
}
if ($batch !== []) {
DB::connection($connection)->table($table)->insertOrIgnore($batch);
}
$this->info(sprintf(
'导入完成:%d 行,仓库简称匹配上 %d 行,跳过 %d 行;库中该表共 %d 行',
$imported,
$mapped,
$skipped,
DB::connection($connection)->table($table)->count(),
));
return self::SUCCESS;
}
/**
* @return array{warehouse:array<string,string>,provider:array<string,string>}
* 仅保留能唯一匹配的键
*/
private function shortNameMap(): array
{
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
$grouped = [];
foreach ($rows as $row) {
$warehouse = trim((string) $row->warehouse);
if ($warehouse === '') {
continue;
}
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
}
$byWarehouse = [];
foreach ($grouped as $warehouse => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byWarehouse[$warehouse] = $unique[0];
}
}
$byProvider = [];
foreach ($rows as $row) {
$provider = trim((string) $row->warehouse_provider);
if ($provider === '') {
continue;
}
$byProvider[$provider][] = trim((string) $row->warehouse_short_name);
}
foreach ($byProvider as $provider => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byProvider[$provider] = $unique[0];
}
}
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
}
private function monthOf(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
$parsed = \DateTimeImmutable::createFromFormat($format, $value);
if ($parsed instanceof \DateTimeImmutable) {
return $parsed->format('Y-m');
}
}
$timestamp = strtotime($value);
return $timestamp === false ? '' : date('Y-m', $timestamp);
}
private function moment(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
private function text(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function number(mixed $value): ?float
{
$text = str_replace(',', '', trim((string) $value));
if ($text === '' || ! is_numeric($text)) {
return null;
}
return (float) $text;
}
}

View File

@ -11,25 +11,24 @@ class ImportJstOrderLabels extends Command
protected $signature = 'jst-order-labels:import
{--from= : 起始下单日期 YYYY-MM-DD}
{--to= : 结束下单日期 YYYY-MM-DD}
{--file= : 聚水潭「导出订单」Excel 路径;给了它就从文件导入(镜像没有的历史区间用)}
{--company=1 : 公司 ID}
{--replace : 先清空该区间已有行}';
protected $description = '把聚水潭订单按标签拆行导入「聚水潭订单标签明细」:默认读共享镜像,给 --file 则读聚水潭导出的订单 Excel';
protected $description = '把聚水潭订单按标签拆行导入「聚水潭订单标签明细」(账单流水数据页)';
public function handle(JstOrderLabelImporter $importer): int
{
$from = (string) $this->option('from');
$to = (string) $this->option('to');
$file = trim((string) $this->option('file'));
try {
$companyId = (int) $this->option('company');
$replace = (bool) $this->option('replace');
$result = $file === ''
? $importer->import($from, $to, $companyId, $replace, 'jst-order-label-import')
: $importer->importFromFile($file, $from ?: null, $to ?: null, $companyId, $replace);
$result = $importer->import(
$from,
$to,
(int) $this->option('company'),
(bool) $this->option('replace'),
'jst-order-label-import',
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());

View File

@ -21,8 +21,7 @@ class ImportProductMonthlyWeightedAverageBase extends Command
protected $signature = 'product:monthly-weighted-average:base
{month : 报表月份,例如 2026-07}
{--file= : 期初基数 Excel(表1),首月使用}
{--from-previous : 用上月的加权平均单价作为本月原始价格}
{--force : 覆盖已经存在的同月基数}';
{--from-previous : 用上月的加权平均单价作为本月原始价格}';
protected $description = '导入/生成商品月加权平均单价的期初基数';
@ -33,18 +32,6 @@ class ImportProductMonthlyWeightedAverageBase extends Command
$rowsTable = 'product_monthly_weighted_average_rows';
$now = now();
$existingRows = DB::connection($connection)->table($rowsTable)
->where('report_month', $month->toDateString())
->count();
$existingState = DB::connection($connection)->table('product_monthly_weighted_average_states')
->where('report_month', $month->toDateString())
->value('status');
if (! $this->option('force') && ($existingRows > 0 || $existingState !== null)) {
$this->error($month->format('Y-m') . ' 已存在基数或计算状态,不自动覆盖;如需人工纠正请显式使用 --force。');
return self::FAILURE;
}
$inventory = $this->inventoryAggregate($connection, $month);
$jiangxiPurchase = $this->jiangxiDeliveryPurchase($connection, $month);
$nanchangOpening = $this->nanchangOpeningQuantity($connection, $month);
@ -211,13 +198,9 @@ class ImportProductMonthlyWeightedAverageBase extends Command
throw new \RuntimeException($month->format('Y-m') . ' 的进销存总表尚未导入。');
}
$mappingModel = new InventoryWarehouseNameMapping();
$mappingConnection = DB::connection($mappingModel->getConnectionName());
$mappingTable = $mappingConnection->getDriverName() === 'sqlite'
? $mappingModel->getTable()
: $mappingModel->getConnection()->getDatabaseName()
. '.' . $mappingConnection->getTablePrefix()
. $mappingModel->getTable();
$mappingTable = (new InventoryWarehouseNameMapping)->getConnection()->getDatabaseName()
. '.' . DB::connection((new InventoryWarehouseNameMapping)->getConnectionName())->getTablePrefix()
. (new InventoryWarehouseNameMapping)->getTable();
$sql = "select m.warehouse_short_name as short_name, r.product_code as product_code,
sum(case when r.warehouse like '%主仓%' then r.opening_quantity else 0 end) as opening_quantity,

View File

@ -1,187 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\StoreViolationRuleRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Throwable;
/**
* 店铺违规信息预警:违规字典维护(Excel 导入 / 更新,按 平台+违规类型 幂等)。
*
* 表头(顺序不限):平台、违规分类、违规类型、匹配关键词、预警级别、是否需要提醒、
* 建议处理动作、处理时效、提醒方式、排序、启用、备注。
* 导入后按需执行 store-violations:recalc-alerts 让存量记录套用新字典。
*/
class ImportStoreViolationRules extends Command
{
protected $signature = 'store-violations:import-rules
{file : xlsx / xls / csv 文件路径}
{--dry-run : 只解析并统计,不写库}';
protected $description = '导入违规字典(预警级别、提醒规则等)';
private const HEADERS = [
'platform' => ['平台', 'platform'],
'violation_category' => ['违规分类', '分类', 'violation_category'],
'violation_type' => ['违规类型', '类型', 'violation_type'],
'match_keywords' => ['匹配关键词', '关键词', 'match_keywords'],
'alert_level' => ['预警级别', '级别', '预警等级', 'alert_level'],
'needs_alert' => ['是否需要提醒', '需提醒', '需要提醒', 'needs_alert'],
'suggested_action' => ['建议处理动作', '处理建议', '建议动作', 'suggested_action'],
'handling_hours' => ['处理时效', '时效', 'handling_hours'],
'remind_channel' => ['提醒方式', 'remind_channel'],
'sort' => ['排序', 'sort'],
'enabled' => ['启用', 'enabled'],
'remark' => ['备注', 'remark'],
];
private const PLATFORM_MAP = [
'淘系' => 'taobao', '淘宝' => 'taobao', '天猫' => 'taobao',
'京东' => 'jd', '京喜' => 'jingxi', '抖音' => 'douyin',
'拼多多' => 'pinduoduo', '阿里巴巴' => 'alibaba',
];
public function handle(): int
{
$file = (string) $this->argument('file');
if (! is_file($file)) {
$this->error('文件不存在:' . $file);
return self::FAILURE;
}
try {
$reader = IOFactory::createReaderForFile($file);
if (method_exists($reader, 'setReadDataOnly')) {
$reader->setReadDataOnly(true);
}
if (method_exists($reader, 'setReadFilter')) {
// 字典文件通常只有几十行;部分导出文件带巨大空白维度,限制读取范围。
$reader->setReadFilter(new class implements \PhpOffice\PhpSpreadsheet\Reader\IReadFilter {
public function readCell($columnAddress, $row, $worksheetName = ''): bool
{
return $row <= 5000;
}
});
}
$matrix = $reader->load($file)->getActiveSheet()->toArray(null, true, true, false);
} catch (Throwable $exception) {
$this->error('无法读取文件:' . $exception->getMessage());
return self::FAILURE;
}
if ($matrix === []) {
$this->error('文件为空。');
return self::FAILURE;
}
$map = [];
foreach ($matrix[0] as $index => $cell) {
$label = mb_strtolower(trim((string) $cell));
foreach (self::HEADERS as $key => $aliases) {
foreach ($aliases as $alias) {
if ($label === mb_strtolower($alias)) {
$map[$key] = $index;
break 2;
}
}
}
}
if (! isset($map['platform'], $map['violation_type'])) {
$this->error('缺少必需表头:平台、违规类型。');
return self::FAILURE;
}
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if (! app(StoreViolationRuleRepository::class)->available()) {
$this->error('违规字典表尚未建立,请先执行数据库迁移。');
return self::FAILURE;
}
$created = 0;
$updated = 0;
$skipped = 0;
$sort = 0;
foreach (array_slice($matrix, 1) as $row) {
$value = static fn (string $key): string => isset($map[$key]) ? trim((string) ($row[$map[$key]] ?? '')) : '';
$platform = $value('platform');
$platform = self::PLATFORM_MAP[$platform] ?? mb_strtolower($platform);
$type = $value('violation_type');
if ($platform === '' || $type === '') {
$skipped++;
continue;
}
$sort += 10;
$levelRaw = strtoupper($value('alert_level'));
$level = in_array($levelRaw, ['P0', 'P1', 'P2'], true) ? $levelRaw : null;
$needsAlertRaw = mb_strtolower($value('needs_alert'));
$needsAlert = ! in_array($needsAlertRaw, ['否', 'no', '0', 'false'], true);
$hoursRaw = $value('handling_hours');
$hours = is_numeric($hoursRaw) ? max((int) $hoursRaw, 0) : null;
$payload = [
'platform' => $platform,
'violation_category' => $value('violation_category') !== '' ? mb_substr($value('violation_category'), 0, 64) : null,
'violation_type' => mb_substr($type, 0, 500),
'match_keywords' => $value('match_keywords') !== '' ? mb_substr($value('match_keywords'), 0, 500) : null,
'alert_level' => $needsAlert ? $level : null,
'needs_alert' => $needsAlert ? 1 : 0,
'suggested_action' => $value('suggested_action') !== '' ? mb_substr($value('suggested_action'), 0, 500) : null,
'handling_hours' => $hours,
'remind_channel' => $value('remind_channel') !== '' ? mb_substr($value('remind_channel'), 0, 191) : null,
'sort' => is_numeric($value('sort')) ? (int) $value('sort') : $sort,
'enabled' => ! in_array(mb_strtolower($value('enabled')), ['否', 'no', '0', 'false'], true) ? 1 : 0,
'remark' => $value('remark') !== '' ? mb_substr($value('remark'), 0, 191) : null,
'updated_at' => now(),
];
$exists = $connection->table(StoreViolationRuleRepository::TABLE)
->where('platform', $platform)
->where('violation_type', $payload['violation_type'])
->exists();
if ($this->option('dry-run')) {
$exists ? $updated++ : $created++;
continue;
}
if ($exists) {
$connection->table(StoreViolationRuleRepository::TABLE)
->where('platform', $platform)
->where('violation_type', $payload['violation_type'])
->update($payload);
$updated++;
} else {
$connection->table(StoreViolationRuleRepository::TABLE)->insert($payload + [
'created_at' => now(),
]);
$created++;
}
}
$this->info(sprintf(
'字典导入%s:新增 %d 条,更新 %d 条,跳过 %d 条。',
$this->option('dry-run') ? '(试运行,未写库)' : '完成',
$created,
$updated,
$skipped,
));
$this->line('提示:需要让存量记录套用新字典时,执行 store-violations:recalc-alerts。');
return self::SUCCESS;
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
class ImportSupplierBookkeepingConfigs extends Command
{
protected $signature = 'supplier-bookkeeping:import-configs {file} {--dry-run : 仅检查文件并输出条数,不写入}';
protected $description = '导入财务提供的厂家记账配置 Excel(供应商、厂家是否记账);新增/更新/跳过,任一行无法识别整批不写入';
public function handle(SupplierBookkeepingConfigImporter $importer, SupplierBookkeepingConfigStore $store): int
{
$path = (string) $this->argument('file');
$rows = $importer->read($path);
if ($this->option('dry-run')) {
$this->info('文件校验通过,共' . count($rows) . '条;未写入。');
return self::SUCCESS;
}
$result = $store->import($rows, '命令行导入', hash_file('sha256', $path));
$this->info("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。");
return self::SUCCESS;
}
}

View File

@ -1,121 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\SupplierSummaryInboundImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Throwable;
/**
* 供应商汇总表月度增量导入:读取已解密工作簿的「明细」子表,仅追加目标月份数据。
*/
class ImportSupplierSummaryInbound extends Command
{
protected $signature = 'inventory:supplier-summary:import
{file : 已解密的供应商汇总 XLSX 文件}
{--month= : 目标月份 YYYY-MM,默认上一个月}
{--result-json= : 将执行结果写入指定 JSON 文件}';
protected $description = '将供应商汇总表「明细」增量导入入库明细数据';
public function handle(SupplierSummaryInboundImporter $importer): int
{
$month = trim((string) $this->option('month'));
if ($month === '') {
$month = now('Asia/Shanghai')->subMonthNoOverflow()->format('Y-m');
}
$lock = Cache::lock('inventory-supplier-summary-inbound-import', 21600);
if (! $lock->get()) {
return $this->finish([
'status' => 'failed',
'report_month' => $month,
'source_filename' => basename((string) $this->argument('file')),
'success' => 0,
'duplicates' => 0,
'failed' => 1,
'mapped' => 0,
'total_in_table' => 0,
'failure_reason_count' => 1,
'failure_reasons' => [[
'row' => 0,
'reason' => '已有供应商汇总导入任务正在执行',
]],
]);
}
try {
$result = $importer->import((string) $this->argument('file'), $month);
} catch (Throwable $exception) {
report($exception);
$result = [
'status' => 'failed',
'report_month' => $month,
'source_filename' => basename((string) $this->argument('file')),
'success' => 0,
'duplicates' => 0,
'failed' => 1,
'mapped' => 0,
'total_in_table' => 0,
'failure_reason_count' => 1,
'failure_reasons' => [[
'row' => 0,
'reason' => $exception->getMessage(),
]],
];
} finally {
$lock->release();
}
return $this->finish($result);
}
/** @param array<string,mixed> $result */
private function finish(array $result): int
{
$reasons = is_array($result['failure_reasons'] ?? null)
? $result['failure_reasons']
: [];
$result['failure_reason_count'] = count($reasons);
$json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (! is_string($json)) {
$json = '{"status":"failed","failure_reasons":[{"row":0,"reason":"结果 JSON 编码失败"}]}';
}
$resultPath = trim((string) $this->option('result-json'));
if ($resultPath !== '') {
File::ensureDirectoryExists(dirname($resultPath));
File::put($resultPath, $json . PHP_EOL);
}
$this->line(sprintf(
'%s 导入状态=%s,成功=%s,重复=%s,失败=%s,目标月份总行数=%s',
(string) ($result['report_month'] ?? ''),
(string) ($result['status'] ?? 'failed'),
number_format((int) ($result['success'] ?? 0)),
number_format((int) ($result['duplicates'] ?? 0)),
number_format((int) ($result['failed'] ?? 0)),
number_format((int) ($result['total_in_table'] ?? 0)),
));
foreach (array_slice($reasons, 0, 10) as $reason) {
$this->warn(sprintf(
'第 %s 行:%s',
(string) ($reason['row'] ?? ''),
(string) ($reason['reason'] ?? '未知错误'),
));
}
if (count($reasons) > 10) {
$this->line(sprintf(
'另有 %s 条失败原因,完整清单见结果 JSON。',
number_format(count($reasons) - 10),
));
}
return ($result['status'] ?? 'failed') === 'success'
? self::SUCCESS
: self::FAILURE;
}
}

View File

@ -1,62 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\StoreReceiptDefaultCompensationSnapshotBuilder;
use Throwable;
class PreviewStoreReceiptDefaultCompensation extends Command
{
protected $signature = 'store-receipt:default-compensation:preview
{month : 账期 YYYY-MM}
{mapping : 最新科目映射表 xlsx 路径}
{--output= : 输出 JSON 路径;默认写入 storage/app/local/performance-details/default-compensation}';
protected $description = '人工核对:按指定科目映射表生成「违约赔付(冲减收入)」金额快照,不写数据库';
public function handle(StoreReceiptDefaultCompensationSnapshotBuilder $builder): int
{
$startedAt = hrtime(true);
$month = trim((string) $this->argument('month'));
$mapping = (string) $this->argument('mapping');
try {
$snapshot = $builder->build($month, $mapping);
$path = (string) ($this->option('output') ?: storage_path(
'app/local/performance-details/default-compensation/' . $month . '.json'
));
$directory = dirname($path);
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
throw new \RuntimeException('无法创建快照目录:' . $directory);
}
$temporary = $path . '.tmp';
$json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if ($json === false || file_put_contents($temporary, $json, LOCK_EX) === false) {
throw new \RuntimeException('无法写入本地快照。');
}
rename($temporary, $path);
$this->info('本地预览快照已生成:' . $path);
$this->line('映射条数:' . $snapshot['mapping_rows']);
$this->line('关联行数:' . count($snapshot['amounts']));
$this->line('金额合计:' . number_format((float) $snapshot['total'], 2, '.', ''));
$this->line('耗时:' . number_format((hrtime(true) - $startedAt) / 1e6, 0) . ' ms');
$this->line('PHP 峰值内存:' . number_format(memory_get_peak_usage(true) / 1024 / 1024, 1) . ' MB');
foreach ($snapshot['source_stats'] as $platform => $stats) {
$this->line(sprintf(
' %s:账单行 %d,金额 %s',
$platform,
$stats['rows'],
number_format((float) $stats['amount'], 2, '.', ''),
));
}
return self::SUCCESS;
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
}
}

View File

@ -1,683 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Throwable;
/**
* 按「附加费报价维护」里启用的超长费规则,从销售出库单拉取结算行。
*
* 口径(第一期只做超长费,且只处理“按尺寸/最长边”的规则):
* - 规则状态必须是 active;
* - 生效条件解析出单边阈值(如“单边超160cm”);
* - 计价标准解析出每单金额(如“8元/单”);
* - 出库单按 logistics_company = 快递名称 匹配,运单号取 l_id,只取已分配运单号的单;
* - 长宽高按明细条形码 sku_id 到商品及库存管理(商品资料 jst_sku)换算,一票多件取最大边;
* - 查件类型/查件原因/退回单号/平台商品ID 在出库单与商品资料里取不到,留空。
*/
class PullCourierSurchargeOverlength extends Command
{
protected $signature = 'courier-surcharge:pull-overlength
{--days=90 : 默认拉取最近多少天(无 --from/--to 时生效)}
{--from= : 出库日期起(含),Y-m-d}
{--to= : 出库日期止(不含),Y-m-d}
{--rule=* : 只跑指定规则 ID,可多次传入}
{--chunk=300 : 每批处理的出库单数量}';
protected $description = '按启用的超长费报价规则,从销售出库单拉取附加费结算行';
public function handle(): int
{
$from = $this->option('from') !== null && $this->option('from') !== ''
? Carbon::parse((string) $this->option('from'))->startOfDay()
: now()->subDays(max((int) $this->option('days'), 1))->startOfDay();
$to = $this->option('to') !== null && $this->option('to') !== ''
? Carbon::parse((string) $this->option('to'))->startOfDay()
: now()->addDay()->startOfDay();
$onlyRules = array_filter(array_map('intval', (array) $this->option('rule')));
$chunkSize = min(max((int) $this->option('chunk'), 50), 500);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$schema = DB::connection($connection)->getSchemaBuilder();
if (! $schema->hasTable('courier_surcharge_settlement_rows')) {
$this->error('结算表 courier_surcharge_settlement_rows 不存在,请先执行迁移。');
return self::FAILURE;
}
$rules = $this->overlengthRules($onlyRules);
if ($rules === []) {
$this->warn('没有启用且可自动计算的超长费规则。');
return self::SUCCESS;
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
$totalWritten = 0;
foreach ($rules as $rule) {
$threshold = $rule['threshold'];
$amount = $rule['amount'];
$this->info(sprintf(
'规则 #%d %s:单边超 %scm 计 %s 元/单,取数 %s ~ %s',
$rule['id'],
$rule['courier'],
$threshold,
$amount,
$from->toDateString(),
$to->toDateString(),
));
$orders = $this->pullOrders($upstream, $rule['courier'], $from, $to, $chunkSize);
if ($orders === []) {
$this->line(' 没有运单号非空的出库单,跳过。');
continue;
}
$written = $this->writeSettlementRows($connection, $rule, $orders, $threshold, $amount, $from, $to);
$totalWritten += $written;
$this->line(' 出库单 ' . count($orders) . ' 单,命中超长并写入 ' . $written . ' 行。');
}
$this->info('完成,共写入 ' . $totalWritten . ' 行结算数据。');
return self::SUCCESS;
}
/**
* @param list<int> $onlyRules
* @return list<array{id:int,courier:string,threshold:float,amount:float}>
*/
private function overlengthRules(array $onlyRules): array
{
$rules = [];
foreach (DB::table('business_courier_surcharge_quotes')->where('status', 'active')->orderBy('id')->get() as $rule) {
$types = json_decode((string) $rule->types, true) ?: [];
if (! in_array('超长费', $types, true)) {
continue;
}
if (! str_contains((string) $rule->mode, '按尺寸') && ! str_contains((string) $rule->mode, '最长边')) {
continue;
}
if ($onlyRules !== [] && ! in_array((int) $rule->id, $onlyRules, true)) {
continue;
}
$condition = (string) $rule->condition;
$standard = (string) $rule->standard;
if (preg_match('/单边超\s*(\d+(?:\.\d+)?)\s*(?:cm|厘米|CM|㎝)/u', $condition, $matched) !== 1) {
$this->warn('规则 #' . $rule->id . ' 的生效条件解析不出单边阈值,已跳过:' . $condition);
continue;
}
$threshold = (float) $matched[1];
if (preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/?\s*单/u', $standard, $priceMatched) !== 1) {
$this->warn('规则 #' . $rule->id . ' 的计价标准解析不出每单金额,已跳过:' . $standard);
continue;
}
$rules[] = [
'id' => (int) $rule->id,
'courier' => trim((string) $rule->courier),
'threshold' => $threshold,
'amount' => (float) $priceMatched[1],
];
}
return $rules;
}
/**
* 拉取出库单(只取已分配运单号的单)。
*
* @return list<array<string,mixed>>
*/
private function pullOrders(
UpstreamReadOnlyQuery $upstream,
string $courier,
Carbon $from,
Carbon $to,
int $chunkSize,
): array {
$orders = [];
$lastId = 0;
while (true) {
$rows = $upstream->select(
'SELECT TOP ' . $chunkSize . ' Id, io_id, o_id, l_id, so_id, shop_name, io_date, weight, f_weight '
. 'FROM dbo.jst_salesOutboundInfo '
. 'WHERE logistics_company = ? AND io_date >= ? AND io_date < ? '
. "AND LEN(ISNULL(l_id, '')) > 0 AND Id > ? "
. 'ORDER BY Id',
[$courier, $from->toDateTimeString(), $to->toDateTimeString(), $lastId],
);
if ($rows === []) {
break;
}
foreach ($rows as $row) {
$row = (array) $row;
$orders[] = $row;
$lastId = max($lastId, (int) $row['Id']);
}
if (count($rows) < $chunkSize) {
break;
}
}
return $orders;
}
/**
* @param list<array<string,mixed>> $orders
*/
private function writeSettlementRows(
string $connection,
array $rule,
array $orders,
float $threshold,
float $amount,
Carbon $from,
Carbon $to,
): int {
$ioIds = array_values(array_filter(array_map(
static fn (array $order): int => (int) ($order['io_id'] ?? 0),
$orders,
), static fn (int $value): bool => $value > 0));
$items = $this->pullItems($ioIds);
$dimensions = $this->dimensionMap($items);
$platformProductIds = $this->platformProductIds($items, $orders);
$onlineOrderNumbers = $this->onlineOrderNumbers($orders);
$now = now();
$written = 0;
$rows = [];
foreach ($orders as $order) {
$ioId = (int) ($order['io_id'] ?? 0);
if ($ioId <= 0) {
continue;
}
$best = null;
foreach ($items[$ioId] ?? [] as $item) {
$skuId = trim((string) ($item['sku_id'] ?? ''));
$dim = $dimensions[$skuId] ?? null;
if ($dim === null) {
continue;
}
$maxSide = max($dim['l'], $dim['w'], $dim['h']);
if ($best === null || $maxSide > $best['max_side']) {
$best = [
'max_side' => $maxSide,
'sku_id' => $skuId,
'i_id' => trim((string) ($item['i_id'] ?? '')),
'length' => $dim['l'],
'width' => $dim['w'],
'height' => $dim['h'],
'platform_product_id' => $platformProductIds[$ioId][$skuId] ?? ($platformProductIds[$ioId][''] ?? null),
];
}
}
if ($best === null || $best['max_side'] <= $threshold) {
continue;
}
$rows[] = [
'rule_id' => $rule['id'],
'surcharge_type' => '超长费',
'courier_name' => $rule['courier'],
'waybill' => trim((string) ($order['l_id'] ?? '')),
'outbound_io_id' => $ioId,
'platform_product_id' => $best['platform_product_id'],
'product_code' => $best['sku_id'] !== '' ? $best['sku_id'] : ($best['i_id'] !== '' ? $best['i_id'] : null),
'shop_name' => mb_substr(trim((string) ($order['shop_name'] ?? '')), 0, 191) ?: null,
'online_order_no' => $onlineOrderNumbers[(int) ($order['o_id'] ?? 0)] ?? null,
'length' => $best['length'],
'width' => $best['width'],
'height' => $best['height'],
'volume' => round($best['length'] * $best['width'] * $best['height'], 2),
'inspection_type' => null,
'inspection_reason' => null,
'return_waybill_no' => null,
'settlement_weight' => null,
'settlement_amount' => $amount,
'settlement_status' => '已计算',
'evidence' => mb_substr(sprintf(
'条形码 %s 尺寸 %s×%s×%s,最长边 %scm > %scm',
$best['sku_id'] !== '' ? $best['sku_id'] : '(无)',
$this->trimNumber($best['length']),
$this->trimNumber($best['width']),
$this->trimNumber($best['height']),
$this->trimNumber($best['max_side']),
$this->trimNumber($threshold),
), 0, 500),
'shipped_at' => $order['io_date'] ?? null,
'pulled_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$written++;
}
$target = DB::connection($connection)->table('courier_surcharge_settlement_rows');
// 同一规则、同一取数窗口内重跑时先清掉旧行,避免残留历史判断。
$target->where('rule_id', $rule['id'])
->where('shipped_at', '>=', $from->toDateTimeString())
->where('shipped_at', '<', $to->toDateTimeString())
->delete();
foreach (array_chunk($rows, 500) as $chunk) {
$target->insert($chunk);
}
return $written;
}
/**
* @param list<int> $ioIds
* @return array<int,list<array<string,mixed>>>
*/
private function pullItems(array $ioIds): array
{
if ($ioIds === []) {
return [];
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
$items = [];
foreach (array_chunk($ioIds, 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $upstream->select(
'SELECT io_id, sku_id, i_id, name, qty FROM dbo.jst_salesOutboundItem WHERE io_id IN (' . $placeholders . ')',
$chunk,
);
foreach ($rows as $row) {
$row = (array) $row;
$items[(int) $row['io_id']][] = $row;
}
}
return $items;
}
/**
* 线上订单号:优先用出库单自带 so_id,为空时按 o_id 到「未发货订单」表兜底。
*
* 部分渠道(如阿里巴巴-常州玖阔)的出库单 so_id 为空,但发货中订单表
* jst_nosend_orders 里仍能按 o_id 找到线上单号。
*
* @param list<array<string,mixed>> $orders
* @return array<int,string>
*/
private function onlineOrderNumbers(array $orders): array
{
$map = [];
$missing = [];
foreach ($orders as $order) {
$oId = (int) ($order['o_id'] ?? 0);
$soId = trim((string) ($order['so_id'] ?? ''));
if ($soId !== '') {
$map[$oId] = mb_substr($soId, 0, 191);
} elseif ($oId > 0) {
$missing[$oId] = true;
}
}
if ($missing === []) {
return $map;
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
foreach (array_chunk(array_keys($missing), 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
"SELECT o_id, so_id FROM dbo.jst_nosend_orders WHERE o_id IN (" . $placeholders . ')'
. " AND LEN(ISNULL(so_id, '')) > 0",
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
}
}
// 仍未补到的(多为已发货、已离开未发货订单表的单),到订单视图兜底;
// 该视图覆盖部分不在 jst_Orders 主表里的店铺(如阿里巴巴-常州玖阔)。
$remaining = array_values(array_diff(array_keys($missing), array_keys($map)));
foreach (array_chunk($remaining, 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
"SELECT o_id, so_id FROM dbo.jst_ordersView WHERE o_id IN (" . $placeholders . ')'
. " AND LEN(ISNULL(so_id, '')) > 0",
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
}
}
// 再兜底:发货数据视图在订单离开 jst_Orders/ordersView/nosend 后仍保留 o_id → so_id,
// 例如 2026-07-31 新佰广1688 已归档的出库单。
$remaining = array_values(array_diff(array_keys($missing), array_keys($map)));
foreach (array_chunk($remaining, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT DISTINCT o_id, so_id FROM dbo.Jst_SendData_View WHERE o_id IN (' . $placeholders . ')'
. ' AND LEN(so_id) > 0',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
}
}
// 最后兜底:全渠道订单列表按月归档,部分天猫订单只在该表保留 o_id → so_id。
$remaining = array_values(array_diff(array_keys($missing), array_keys($map)));
if ($remaining !== []) {
$monthById = [];
foreach ($orders as $order) {
$oId = (int) ($order['o_id'] ?? 0);
$ioDate = trim((string) ($order['io_date'] ?? ''));
if ($oId <= 0 || $ioDate === '') {
continue;
}
try {
$monthById[$oId] = Carbon::parse($ioDate)->format('Ym');
} catch (Throwable) {
// io_date 无法解析时只查当前表。
}
}
$tables = ['jst_qmcust_orderlist' => array_values($remaining)];
foreach ($remaining as $oId) {
$month = $monthById[$oId] ?? null;
if ($month !== null) {
$tables['jst_qmcust_orderlist_' . $month][] = $oId;
}
}
foreach ($tables as $table => $ids) {
foreach (array_chunk($ids, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT o_id, so_id FROM dbo.' . $table . ' WHERE o_id IN (' . $placeholders . ')'
. ' AND LEN(so_id) > 0',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
}
}
}
}
return $map;
}
/**
* 平台商品ID:查件工单里的 platformItemId 与订单/发货明细的 shop_i_id 同源。
* 优先按 io_id + sku_id 从发货明细视图取;缺行时按 o_id 到全渠道订单明细(当前表 + 月度归档)兜底。
*
* @param array<int,list<array<string,mixed>>> $items
* @param list<array<string,mixed>> $orders
* @return array<int,array<string,string>>
*/
private function platformProductIds(array $items, array $orders): array
{
$map = [];
$ioIds = array_keys($items);
if ($ioIds === []) {
return $map;
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
foreach (array_chunk($ioIds, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT io_id, sku_id, shop_i_id FROM dbo.Jst_SendDataDetail_View WHERE io_id IN (' . $placeholders . ')'
. ' AND LEN(shop_i_id) > 0',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$ioId = (int) ($row['io_id'] ?? 0);
$skuId = trim((string) ($row['sku_id'] ?? ''));
$shopIId = mb_substr(trim((string) ($row['shop_i_id'] ?? '')), 0, 191);
if ($ioId <= 0 || $shopIId === '') {
continue;
}
if ($skuId !== '') {
$map[$ioId][$skuId] = $shopIId;
}
$map[$ioId][''] ??= $shopIId;
}
}
// 剩余按 o_id 到全渠道订单明细(当前表 + 月度归档)兜底。
$oIdByIoId = [];
$monthByIoId = [];
foreach ($orders as $order) {
$ioId = (int) ($order['io_id'] ?? 0);
$oId = (int) ($order['o_id'] ?? 0);
$ioDate = trim((string) ($order['io_date'] ?? ''));
if ($ioId <= 0 || $oId <= 0) {
continue;
}
$oIdByIoId[$ioId] = $oId;
if ($ioDate === '') {
continue;
}
try {
$monthByIoId[$ioId] = Carbon::parse($ioDate)->format('Ym');
} catch (Throwable) {
// io_date 无法解析时只查当前表。
}
}
$missing = [];
foreach ($ioIds as $ioId) {
if (($map[$ioId] ?? []) !== []) {
continue;
}
$oId = $oIdByIoId[$ioId] ?? 0;
if ($oId > 0) {
$missing[$ioId] = $oId;
}
}
if ($missing === []) {
return $map;
}
$tables = ['jst_qmcust_orderlist_item' => $missing];
foreach ($missing as $ioId => $oId) {
$month = $monthByIoId[$ioId] ?? null;
if ($month !== null) {
$tables['jst_qmcust_orderlist_item_' . $month][$ioId] = $oId;
}
}
foreach ($tables as $table => $idsByIo) {
$oIds = array_values(array_unique(array_values($idsByIo)));
foreach (array_chunk($oIds, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT o_id, sku_id, shop_i_id FROM dbo.' . $table . ' WHERE o_id IN (' . $placeholders . ')'
. ' AND LEN(shop_i_id) > 0',
$chunk,
);
} catch (Throwable) {
continue;
}
$byOrder = [];
foreach ($rows as $row) {
$row = (array) $row;
$skuId = trim((string) ($row['sku_id'] ?? ''));
$shopIId = mb_substr(trim((string) ($row['shop_i_id'] ?? '')), 0, 191);
if ($shopIId === '') {
continue;
}
$byOrder[(int) ($row['o_id'] ?? 0)][] = ['sku_id' => $skuId, 'shop_i_id' => $shopIId];
}
foreach ($idsByIo as $ioId => $oId) {
foreach ($byOrder[$oId] ?? [] as $entry) {
if ($entry['sku_id'] !== '') {
$map[$ioId][$entry['sku_id']] = $entry['shop_i_id'];
}
$map[$ioId][''] ??= $entry['shop_i_id'];
}
}
}
}
return $map;
}
/**
* @param array<int,list<array<string,mixed>>> $items
* @return array<string,array{l:float,w:float,h:float}>
*/
private function dimensionMap(array $items): array
{
$skuIds = [];
foreach ($items as $rows) {
foreach ($rows as $row) {
$skuId = trim((string) ($row['sku_id'] ?? ''));
if ($skuId !== '') {
$skuIds[$skuId] = true;
}
}
}
$skuIds = array_keys($skuIds);
$dimensions = [];
// 商品及库存管理(商品资料)已在共享镜像里,直接读镜像,不再压上游。
foreach (array_chunk($skuIds, 500) as $chunk) {
try {
$rows = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('ops_jst_sku')
->whereIn('sku_id', $chunk)
->get(['sku_id', 'l', 'w', 'h']);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$l = (float) $row->l;
$w = (float) $row->w;
$h = (float) $row->h;
if ($l > 0 && $w > 0 && $h > 0) {
$dimensions[trim((string) $row->sku_id)] = ['l' => $l, 'w' => $w, 'h' => $h];
}
}
}
return $dimensions;
}
private function trimNumber(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
}

View File

@ -1,727 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Throwable;
/**
* 按「附加费报价维护」里启用的退回费 / 拦截费规则,从查件扣单拉取结算行。
*
* 口径:
* - 规则状态 active、生效条件含「查件扣单」;
* - 退回费:types 含「退回费」,支持 固定单价 / 按重量;已退回(售后退货退款
* receive_date 进仓时间 + r_qty 实退数量)按标准计价,未退回计 0;
* 结算重量取商品资料 ops_jst_sku.weight,按实退数量汇总;未退回时按发货数量展示;
* - 拦截费:types 含「拦截费」且模式为固定单价,查件扣单即按标准金额计价;
* - 扣单来源:共享镜像 js_jst_work_order_info 中 title=查件、查件类型=扣单 的工单。
*/
class PullCourierSurchargeReturn extends Command
{
private const CHECK_TYPE_FIELD = 'SELECT_CASCADEField1413856620250813172526';
protected $signature = 'courier-surcharge:pull-return
{--days=90 : 默认拉取最近多少天(无 --from/--to 时生效,按查件工单创建时间)}
{--from= : 查件工单创建起(含),Y-m-d}
{--to= : 查件工单创建止(不含),Y-m-d}
{--rule=* : 只跑指定规则 ID,可多次传入}
{--type=* : 只跑指定附加费类型(退回费/拦截费),可多次传入}
{--limit=0 : 调试用,最多处理多少张扣单工单,0 表示不限}';
protected $description = '按启用的退回费/拦截费报价规则,从查件扣单拉取结算行';
public function handle(): int
{
$from = $this->option('from') !== null && $this->option('from') !== ''
? Carbon::parse((string) $this->option('from'))->startOfDay()
: now()->subDays(max((int) $this->option('days'), 1))->startOfDay();
$to = $this->option('to') !== null && $this->option('to') !== ''
? Carbon::parse((string) $this->option('to'))->startOfDay()
: now()->addDay()->startOfDay();
$onlyRules = array_filter(array_map('intval', (array) $this->option('rule')));
$onlyTypes = array_values(array_filter(
array_map(static fn ($type): string => trim((string) $type), (array) $this->option('type')),
static fn (string $type): bool => in_array($type, ['退回费', '拦截费'], true),
));
$limit = max((int) $this->option('limit'), 0);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$schema = DB::connection($connection)->getSchemaBuilder();
if (! $schema->hasTable('courier_surcharge_settlement_rows')) {
$this->error('结算表 courier_surcharge_settlement_rows 不存在,请先执行迁移。');
return self::FAILURE;
}
if (! $schema->hasTable('js_jst_work_order_info')) {
$this->error('查件工单镜像 js_jst_work_order_info 不存在,请先完成镜像同步。');
return self::FAILURE;
}
$rules = $this->rules($onlyRules, $onlyTypes);
if ($rules === []) {
$this->warn('没有启用且可自动计算的退回费/拦截费规则。');
return self::SUCCESS;
}
$orders = $this->pullDeductOrders($connection, $from, $to, $limit);
$this->info(sprintf('取数 %s ~ %s,扣单工单 %d 单。', $from->toDateString(), $to->toDateString(), count($orders)));
if ($orders === []) {
return self::SUCCESS;
}
$ordersByCourier = [];
foreach ($orders as $order) {
$ordersByCourier[$order['courier']][] = $order;
}
$totalWritten = 0;
foreach ($rules as $rule) {
$matched = $ordersByCourier[$rule['courier']] ?? [];
if ($matched === []) {
$this->line('规则 #' . $rule['id'] . ' ' . $rule['courier'] . ':没有匹配的扣单工单,跳过。');
continue;
}
$written = $this->writeSettlementRows($connection, $rule, $matched);
$totalWritten += $written;
$this->line('规则 #' . $rule['id'] . ' ' . $rule['courier'] . '(' . $rule['type'] . '/' . $rule['mode'] . '):扣单 ' . count($matched) . ' 单,写入 ' . $written . ' 行。');
}
$this->info('完成,共写入 ' . $totalWritten . ' 行附加费结算数据。');
return self::SUCCESS;
}
/**
* @param list<int> $onlyRules
* @param list<string> $onlyTypes
* @return list<array{id:int,type:string,courier:string,mode:string,value:float,effective_at:?string}>
*/
private function rules(array $onlyRules, array $onlyTypes): array
{
$rules = [];
foreach (DB::table('business_courier_surcharge_quotes')->where('status', 'active')->orderBy('id')->get() as $rule) {
$types = json_decode((string) $rule->types, true) ?: [];
$candidateTypes = array_values(array_intersect(['退回费', '拦截费'], $types));
if ($candidateTypes === []) {
continue;
}
if ($onlyRules !== [] && ! in_array((int) $rule->id, $onlyRules, true)) {
continue;
}
$condition = trim((string) $rule->condition);
if (! str_contains($condition, '扣单')) {
$this->warn('规则 #' . $rule->id . ' 的生效条件不含「扣单」,已跳过:' . $condition);
continue;
}
$mode = trim((string) $rule->mode);
$standard = trim((string) $rule->standard);
foreach ($candidateTypes as $type) {
if ($onlyTypes !== [] && ! in_array($type, $onlyTypes, true)) {
continue;
}
if ($type === '退回费') {
if ($mode === '固定单价') {
$value = $this->parseFixedAmount($standard);
} elseif ($mode === '按重量') {
$value = $this->parseWeightRate($standard);
} else {
$this->line('规则 #' . $rule->id . ' ' . $rule->courier . ' 的退回费模式「' . $mode . '」暂不支持自动计算,已跳过。');
continue;
}
} else {
if ($mode === '固定单价') {
$value = $this->parseFixedAmount($standard);
} else {
$this->line('规则 #' . $rule->id . ' ' . $rule->courier . ' 的拦截费模式「' . $mode . '」暂不支持自动计算,已跳过。');
continue;
}
}
if ($value === null) {
$this->warn('规则 #' . $rule->id . ' 的' . $type . '计价标准解析失败,已跳过:' . $standard);
continue;
}
$rules[] = [
'id' => (int) $rule->id,
'type' => $type,
'courier' => trim((string) $rule->courier),
'mode' => $mode,
'value' => $value,
'effective_at' => $rule->effective_at !== null && $rule->effective_at !== ''
? (string) $rule->effective_at
: null,
];
}
}
return $rules;
}
private function parseFixedAmount(string $standard): ?float
{
return preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $matched) === 1
? (float) $matched[1]
: null;
}
private function parseWeightRate(string $standard): ?float
{
return preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/\s*(?:kg|KG|公斤|千克)/u', $standard, $matched) === 1
? (float) $matched[1]
: null;
}
/**
* 从查件工单镜像按创建时间拉取扣单工单。
*
* @return list<array<string,mixed>>
*/
private function pullDeductOrders(string $connection, Carbon $from, Carbon $to, int $limit): array
{
$orders = [];
$lastId = 0;
$batchSize = 500;
while (true) {
$rows = DB::connection($connection)->table('js_jst_work_order_info')
->where('title', '查件')
->where('businessFieldList', 'like', '%"value":"扣单/%')
->where('created', '>=', $from->toDateTimeString())
->where('created', '<', $to->toDateTimeString())
->where('Id', '>', $lastId)
->orderBy('Id')
->limit($batchSize)
->get(['Id', 'woNo', 'created', 'businessFieldList']);
if ($rows->isEmpty()) {
break;
}
foreach ($rows as $row) {
$lastId = max($lastId, (int) $row->Id);
$order = $this->parseWorkOrder((string) $row->businessFieldList, (string) $row->woNo, (string) $row->created);
if ($order !== null) {
$orders[] = $order;
}
if ($limit > 0 && count($orders) >= $limit) {
break 2;
}
}
if ($rows->count() < $batchSize) {
break;
}
}
return $orders;
}
/**
* @return array<string,mixed>|null
*/
private function parseWorkOrder(string $json, string $woNo, string $created): ?array
{
$fields = json_decode($json, true);
if (! is_array($fields)) {
return null;
}
$flat = [];
foreach ($fields as $field) {
if (is_array($field) && isset($field['fieldId'])) {
$flat[(string) $field['fieldId']] = $field;
}
}
$waybill = trim((string) ($flat['lIdField']['value'] ?? ''));
$oId = (int) ($flat['o_id']['value'] ?? 0);
$courier = trim((string) ($flat['logisticsField']['value'] ?? ''));
$cascade = trim((string) ($flat[self::CHECK_TYPE_FIELD]['value'] ?? ''));
if ($waybill === '' || $courier === '' || ! str_starts_with($cascade, '扣单')) {
return null;
}
[$inspectionType, $inspectionReason] = array_pad(explode('/', $cascade, 2), 2, '');
$items = [];
foreach (($flat['itemInfo']['childFieldList'] ?? []) as $group) {
if (! is_array($group)) {
continue;
}
$item = [];
foreach ($group as $field) {
if (is_array($field) && isset($field['fieldId'])) {
$item[(string) $field['fieldId']] = $field['value'] ?? null;
}
}
$skuId = strtoupper(trim((string) ($item['skuId'] ?? '')));
if ($skuId === '') {
continue;
}
$items[] = [
'sku_id' => $skuId,
'qty' => (float) ($item['qty'] ?? 0),
'platform_product_id' => mb_substr(trim((string) ($item['platformItemId'] ?? '')), 0, 191),
'i_id' => trim((string) ($item['iid'] ?? '')),
'name' => mb_substr(trim((string) ($item['name'] ?? '')), 0, 191),
];
}
$createdAt = null;
try {
$createdAt = Carbon::parse($created);
} catch (Throwable) {
// created 无法解析时仍保留工单,仅规则生效时间过滤会放宽。
}
return [
'wo_no' => $woNo,
'waybill' => $waybill,
'o_id' => $oId,
'courier' => $courier,
'online_order_no' => mb_substr(trim((string) ($flat['orderField']['value'] ?? '')), 0, 191),
'shop_name' => mb_substr(trim((string) ($flat['shopField']['valueLabel'] ?? ($flat['shopField']['value'] ?? ''))), 0, 191),
'inspection_type' => mb_substr(trim((string) $inspectionType), 0, 60),
'inspection_reason' => mb_substr(trim((string) $inspectionReason), 0, 120),
'send_time' => trim((string) ($flat['orderSendTime']['value'] ?? '')),
'created_at' => $createdAt,
'items' => $items,
];
}
/**
* @param array{id:int,courier:string,mode:string,value:float,effective_at:?string} $rule
* @param list<array<string,mixed>> $orders
*/
private function writeSettlementRows(string $connection, array $rule, array $orders): int
{
$effectiveAt = null;
if (($rule['effective_at'] ?? null) !== null) {
try {
$effectiveAt = Carbon::parse((string) $rule['effective_at'])->startOfDay();
} catch (Throwable) {
// 规则生效时间无法解析时不做下界过滤。
}
}
if ($effectiveAt !== null) {
$orders = array_values(array_filter(
$orders,
static fn (array $order): bool => ! $order['created_at'] instanceof Carbon
|| $order['created_at']->greaterThanOrEqualTo($effectiveAt),
));
}
if ($orders === []) {
return 0;
}
$oIds = [];
$waybills = [];
$skuIds = [];
foreach ($orders as $order) {
if ((int) ($order['o_id'] ?? 0) > 0) {
$oIds[(int) $order['o_id']] = true;
}
$waybills[(string) $order['waybill']] = true;
foreach ($order['items'] as $item) {
$skuIds[(string) $item['sku_id']] = true;
}
}
$type = (string) $rule['type'];
$returns = $type === '退回费' ? $this->pullReturns($orders) : [];
$weights = $this->pullWeights($connection, array_keys($skuIds));
$now = now();
$rows = [];
$hasReturnByWaybill = [];
foreach ($orders as $order) {
$matched = [];
$receiveDates = [];
$returnWaybills = [];
$hasReturn = false;
$settlementWeight = 0.0;
$orderWeight = 0.0;
foreach ($order['items'] as $item) {
$weight = $weights[(string) $item['sku_id']] ?? null;
if ($weight !== null) {
$orderWeight += (float) $item['qty'] * $weight;
}
}
if ($type === '拦截费') {
// 拦截费口径:查件扣单即按固定单价结算,不要求退货进仓。
$amount = (float) $rule['value'];
$status = '已计算';
} else {
foreach ($returns[(int) ($order['o_id'] ?? 0)] ?? [] as $return) {
$rQty = (float) ($return['r_qty'] ?? 0);
$receiveDate = trim((string) ($return['receive_date'] ?? ''));
if ($rQty <= 0 || $receiveDate === '') {
continue;
}
$hasReturn = true;
$matched[] = $return;
$receiveDates[] = $receiveDate;
$returnWaybill = trim((string) ($return['l_id'] ?? ''));
if ($returnWaybill !== '') {
$returnWaybills[$returnWaybill] = true;
}
}
$returnedWeight = 0.0;
foreach ($matched as $return) {
$weight = $weights[strtoupper(trim((string) ($return['sku_id'] ?? '')))] ?? null;
if ($weight !== null) {
$returnedWeight += (float) $return['r_qty'] * $weight;
}
}
$settlementWeight = $hasReturn
? ($returnedWeight > 0 ? $returnedWeight : $orderWeight)
: $orderWeight;
if ($rule['mode'] === '固定单价') {
$amount = $hasReturn ? (float) $rule['value'] : 0.0;
$status = '已计算';
} elseif (! $hasReturn) {
$amount = 0.0;
$status = '已计算';
} elseif ($settlementWeight > 0) {
$amount = round($settlementWeight * (float) $rule['value'], 2);
$status = '已计算';
} else {
$amount = 0.0;
$status = '待核价';
}
}
$primary = $this->primaryItem($order['items'], $matched);
$waybill = (string) $order['waybill'];
// 同一运单可能有多张扣单工单;只保留一行,优先保留有退回记录的那行。
if (($hasReturnByWaybill[$waybill] ?? null) === true && ! $hasReturn) {
continue;
}
$rows[$waybill] = [
'rule_id' => $rule['id'],
'surcharge_type' => $type,
'courier_name' => $rule['courier'],
'waybill' => $waybill,
'outbound_io_id' => null,
'platform_product_id' => $primary['platform_product_id'] !== '' ? $primary['platform_product_id'] : null,
'product_code' => $primary['sku_id'] !== '' ? $primary['sku_id'] : ($primary['i_id'] !== '' ? $primary['i_id'] : null),
'shop_name' => $order['shop_name'] !== '' ? $order['shop_name'] : null,
'online_order_no' => $order['online_order_no'] !== '' ? $order['online_order_no'] : null,
'length' => null,
'width' => null,
'height' => null,
'volume' => null,
'inspection_type' => $order['inspection_type'],
'inspection_reason' => $order['inspection_reason'],
'return_waybill_no' => $returnWaybills === []
? null
: mb_substr(implode(',', array_keys($returnWaybills)), 0, 191),
'settlement_weight' => $settlementWeight > 0 ? round($settlementWeight, 2) : null,
'settlement_amount' => $amount,
'settlement_status' => $status,
'evidence' => mb_substr($this->evidence($rule, $order, $hasReturn, $matched, $receiveDates, $settlementWeight, $amount), 0, 500),
'shipped_at' => $this->shippedAt($order),
'pulled_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$hasReturnByWaybill[$waybill] = $hasReturn;
}
foreach (array_chunk(array_keys($waybills), 500) as $chunk) {
DB::connection($connection)->table('courier_surcharge_settlement_rows')
->where('rule_id', $rule['id'])
->where('surcharge_type', $type)
->whereIn('waybill', $chunk)
->delete();
}
foreach (array_chunk(array_values($rows), 500) as $chunk) {
DB::connection($connection)->table('courier_surcharge_settlement_rows')->insert($chunk);
}
return count($rows);
}
/**
* 售后退货退款:进仓时间取 receive_date,实退数量取 r_qty。
*
* @param list<array<string,mixed>> $orders
* @return array<int,list<array<string,mixed>>>
*/
private function pullReturns(array $orders): array
{
$map = [];
$oIds = [];
$oIdByWaybill = [];
foreach ($orders as $order) {
$oId = (int) ($order['o_id'] ?? 0);
$waybill = trim((string) ($order['waybill'] ?? ''));
if ($oId > 0) {
$oIds[$oId] = true;
}
if ($oId > 0 && $waybill !== '') {
$oIdByWaybill[$waybill] = $oId;
}
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('operations_replica');
foreach (array_chunk(array_keys($oIds), 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->selectReturns($upstream, 'a.o_id IN (' . $placeholders . ')', $chunk) as $row) {
$oId = (int) ($row['o_id'] ?? 0);
if ($oId > 0) {
$map[$oId][] = $row;
}
}
}
// 部分售后单的 o_id 与查件工单不一致,按原始运单号再兜底一次。
$remainingWaybills = [];
foreach ($oIdByWaybill as $waybill => $oId) {
if (($map[$oId] ?? []) === []) {
$remainingWaybills[] = $waybill;
}
}
foreach (array_chunk($remainingWaybills, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->selectReturns($upstream, 'a.l_id IN (' . $placeholders . ')', $chunk) as $row) {
$oId = $oIdByWaybill[trim((string) ($row['l_id'] ?? ''))] ?? 0;
if ($oId > 0) {
$map[$oId][] = $row;
}
}
}
return $map;
}
/**
* @param list<mixed> $bindings
* @return list<array<string,mixed>>
*/
private function selectReturns(UpstreamReadOnlyQuery $upstream, string $where, array $bindings): array
{
try {
$rows = $upstream->select(
'SELECT a.o_id, a.as_id, a.l_id, a.status, CONVERT(varchar(19), a.confirm_date, 120) AS confirm_date, '
. 'ri.sku_id, ri.qty, ri.r_qty, '
. 'CONVERT(varchar(19), ri.receive_date, 120) AS receive_date '
. 'FROM rpt.jst_returnAndRefundInfo a '
. 'INNER JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = a.as_id '
. 'WHERE ' . $where,
$bindings,
);
} catch (Throwable) {
return [];
}
return array_map(static fn ($row): array => (array) $row, $rows);
}
/**
* @param list<string> $skuIds
* @return array<string,float>
*/
private function pullWeights(string $connection, array $skuIds): array
{
$map = [];
foreach (array_chunk($skuIds, 500) as $chunk) {
$rows = DB::connection($connection)->table('ops_jst_sku')
->whereIn('sku_id', $chunk)
->get(['sku_id', 'weight']);
foreach ($rows as $row) {
$skuId = strtoupper(trim((string) $row->sku_id));
$weight = $row->weight;
if ($skuId === '' || $weight === null || $weight === '' || isset($map[$skuId])) {
continue;
}
$map[$skuId] = (float) $weight;
}
}
return $map;
}
/**
* @param list<array<string,mixed>> $items
* @param list<array<string,mixed>> $returns
* @return array{sku_id:string,i_id:string,platform_product_id:string,name:string}
*/
private function primaryItem(array $items, array $returns): array
{
$returnSkus = [];
foreach ($returns as $return) {
$skuId = strtoupper(trim((string) ($return['sku_id'] ?? '')));
if ($skuId !== '') {
$returnSkus[$skuId] = true;
}
}
foreach ($items as $item) {
if (isset($returnSkus[$item['sku_id']])) {
return $item;
}
}
foreach ($items as $item) {
if ($item['platform_product_id'] !== '') {
return $item;
}
}
return $items[0] ?? ['sku_id' => '', 'i_id' => '', 'platform_product_id' => '', 'name' => ''];
}
/**
* @param array{id:int,type:string,courier:string,mode:string,value:float,effective_at:?string} $rule
* @param array<string,mixed> $order
* @param list<array<string,mixed>> $matched
* @param list<string> $receiveDates
*/
private function evidence(
array $rule,
array $order,
bool $hasReturn,
array $matched,
array $receiveDates,
float $settlementWeight,
float $amount,
): string {
$parts = ['查件扣单/' . $order['inspection_reason']];
if (($rule['type'] ?? '退回费') === '拦截费') {
$parts[] = '拦截费规则 ' . $this->trimNumber((float) $rule['value']) . '元/单';
$parts[] = '结算金额 ' . number_format($amount, 2, '.', '');
return implode(';', $parts);
}
if ($hasReturn) {
$returnQty = 0.0;
$confirmDates = [];
foreach ($matched as $return) {
$returnQty += (float) ($return['r_qty'] ?? 0);
$confirmDate = trim((string) ($return['confirm_date'] ?? ''));
if ($confirmDate !== '') {
$confirmDates[] = $confirmDate;
}
}
if ($confirmDates !== []) {
$parts[] = '退款确认 ' . max($confirmDates);
}
$parts[] = '售后退货退款已进仓 ' . ($receiveDates === [] ? '' : max($receiveDates));
$parts[] = '实退 ' . $this->trimNumber($returnQty);
} else {
$parts[] = '售后退货退款无进仓/实退记录';
}
$parts[] = '商品资料重量合计 ' . $this->trimNumber($settlementWeight) . 'kg';
$parts[] = $rule['mode'] === '固定单价'
? '规则 ' . $this->trimNumber((float) $rule['value']) . '元/单'
: '规则 ' . $this->trimNumber((float) $rule['value']) . '元/kg';
$parts[] = '结算金额 ' . number_format($amount, 2, '.', '');
return implode(';', $parts);
}
/**
* @param array<string,mixed> $order
*/
private function shippedAt(array $order): ?string
{
$sendTime = trim((string) ($order['send_time'] ?? ''));
if ($sendTime !== '') {
try {
return Carbon::parse($sendTime)->toDateTimeString();
} catch (Throwable) {
// 发货时间无法解析时回退到查件工单创建时间。
}
}
return $order['created_at'] instanceof Carbon ? $order['created_at']->toDateTimeString() : null;
}
private function trimNumber(float $value): string
{
$text = rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
return $text === '' ? '0' : $text;
}
}

View File

@ -1,41 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreViolationNotifier;
use Illuminate\Console\Command;
/**
* 店铺违规信息预警:P0/P1 即时推送(每天 09:00 起由生产 cron 每 5 分钟触发)。
*/
class PushStoreViolationAlerts extends Command
{
protected $signature = 'store-violations:push-instant
{--limit=200 : 单轮最多推送条数}
{--retry-failed : 同时重试推送失败的记录}';
protected $description = '推送店铺违规预警即时消息(P0/P1,含责任人@与去认领链接)';
public function handle(StoreViolationNotifier $notifier): int
{
if (! $notifier->enabled()) {
$this->line('推送未配置,跳过。');
return self::SUCCESS;
}
$summary = $notifier->pushInstant(
max((int) $this->option('limit'), 1),
(bool) $this->option('retry-failed'),
);
$this->info(sprintf(
'即时推送:待推 %d 条,成功 %d 条,失败 %d 条。',
$summary['attempted'],
$summary['succeeded'],
$summary['failed'],
));
return self::SUCCESS;
}
}

View File

@ -1,38 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreViolationNotifier;
use Illuminate\Console\Command;
/**
* 店铺违规信息预警:P2 每日 10:00 汇总日报。
*/
class PushStoreViolationDigest extends Command
{
protected $signature = 'store-violations:push-digest';
protected $description = '推送店铺违规预警每日汇总(P2 汇总与超时未认领点名)';
public function handle(StoreViolationNotifier $notifier): int
{
if (! $notifier->enabled()) {
$this->line('推送未配置,跳过。');
return self::SUCCESS;
}
$summary = $notifier->pushDigest();
$this->info(sprintf(
'日报推送:%s;P0 %d / P1 %d / P2 %d,超时未认领 %d 条。',
$summary['sent'] ? '已发送' : '发送失败',
(int) ($summary['pending']['P0'] ?? 0),
(int) ($summary['pending']['P1'] ?? 0),
(int) ($summary['pending']['P2'] ?? 0),
(int) $summary['overdue'],
));
return self::SUCCESS;
}
}

View File

@ -1,79 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreViolationAlertCalculator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
/**
* 店铺违规信息预警:按当前字典/归属重新计算全部记录的预警派生字段。
*
* 用于字典维护或店铺归属调整后的校准;不触碰处理状态、忽略原因与推送状态。
*/
class RecalcStoreViolationAlerts extends Command
{
protected $signature = 'store-violations:recalc-alerts
{--platform= : 只重算指定平台}
{--chunk=500 : 每批更新行数}';
protected $description = '重算店铺违规记录的预警级别、责任人与处理截止';
public function handle(StoreViolationAlertCalculator $calculator, StoreViolationRecordRepository $repository): int
{
if (! $repository->available()) {
$this->error('统一表 store_violation_records 尚未建立。');
return self::FAILURE;
}
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$chunk = max(min((int) $this->option('chunk'), 2000), 100);
$platform = trim((string) $this->option('platform'));
$total = 0;
$query = $connection->table(StoreViolationRecordRepository::TABLE)
->select(['id', 'platform', 'violation_no', 'shop_name', 'violation_type', 'violation_reason', 'violation_date', 'appeal_deadline'])
->orderBy('id');
if ($platform !== '') {
$query->where('platform', $platform);
}
$query->chunk($chunk, function ($rows) use ($calculator, $connection, &$total): void {
$updates = [];
foreach ($rows as $row) {
$matchType = trim((string) ($row->violation_type ?? '')) !== ''
? (string) $row->violation_type
: ($row->violation_reason ?? null);
// upsert 的插入侧会校验非空列,带上 platform / violation_no 以通过严格模式;
// 命中已有记录时只会更新派生字段(update 列表)。
$updates[] = [
'id' => $row->id,
'platform' => (string) $row->platform,
'violation_no' => (string) ($row->violation_no ?? ''),
] + $calculator->calculate(
(string) $row->platform,
$row->shop_name,
$matchType,
$row->violation_date ? (string) $row->violation_date : null,
$row->appeal_deadline ? (string) $row->appeal_deadline : null,
);
}
if ($updates !== []) {
$connection->table(StoreViolationRecordRepository::TABLE)->upsert(
$updates,
['id'],
StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS,
);
$total += count($updates);
}
});
$this->info(sprintf('预警重算完成:%d 行。', $total));
return self::SUCCESS;
}
}

View File

@ -1,35 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
use Throwable;
class RecalculateFinanceLedger extends Command
{
protected $signature = 'finance-ledger:recalculate';
protected $description = '重算财务台账的每日资金流水、账户余额和店铺余额';
public function handle(FinanceLedgerCalculator $calculator): int
{
try {
$result = $calculator->recalculate();
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['账户数', $result['accounts']],
['流水行数', $result['flows']],
['期末余额合计', number_format($result['balance_sum'], 2)],
['期初日期', $result['opening_date']],
]);
$this->info('财务台账流水重算完成。');
return self::SUCCESS;
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
class RefreshStoreReportPaidSales extends Command
{
protected $signature = 'business-mirror:refresh-paid-sales {--full : 强制全量校准派生汇总}';
protected $description = '只刷新业绩明细派生的销量汇总,不修改原始财务明细';
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
{
$result = $synchronizer->syncPaidSales((bool) $this->option('full'));
$this->info(json_encode($result));
return $result['status'] === 'succeeded' ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -1,47 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\ProductMonthlyWeightedAverageReporter;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class ReportProductMonthlyWeightedAverage extends Command
{
protected $signature = 'product:monthly-weighted-average:report
{month : 报表月份,例如 2026-09}
{--result-json= : 将结果写入指定 JSON 文件}';
protected $description = '输出商品月加权平均单价结果简报与差异分类';
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
{
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
$report = $reporter->build(
(string) config('upstream_mirrors.connection', 'business_mirror'),
$month,
);
$path = trim((string) $this->option('result-json'));
if ($path !== '') {
File::ensureDirectoryExists(dirname($path));
File::put(
$path,
json_encode(
$report,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
) . PHP_EOL,
);
}
$this->line($reporter->brief($report));
if (($report['status'] ?? null) === 'review_required') {
$this->warn('存在待人工复核差异,完整清单已写入结果 JSON。');
}
return ($report['status'] ?? null) === 'missing'
? self::FAILURE
: self::SUCCESS;
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\AccountBillsExportTasks;
class RunAccountBillsExportQueue extends Command
{
protected $signature = 'account-bills:exports:run {--max-jobs=1}';
protected $description = '依次消费账单导出任务,生产从受限批容器启动';
public function handle(AccountBillsExportTasks $tasks): int
{
$limit = max(0, (int) $this->option('max-jobs'));
for ($processed = 0; $limit === 0 || $processed < $limit; $processed++) {
$task = $tasks->nextQueued();
if ($task === null) break;
$code = $this->call('account-bills:export-run', ['token' => $task['token']]);
if ($code === 75) break;
}
return self::SUCCESS;
}
}

View File

@ -1,209 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\JdBillPlatform;
use Modules\EcommerceFinance\Repositories\DewuSettlementBillRepository;
use Modules\EcommerceFinance\Repositories\JdDailyBillRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingDewuBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\TaobaoWechatSettlementBillRepository;
use Modules\EcommerceFinance\Services\AccountBillsExportTasks;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use Throwable;
/**
* 后台执行「店铺收款账户账单」导出任务:生成 CSV 并更新任务状态,供页面列表下载。
*/
class RunAccountBillsExportTask extends Command
{
protected $signature = 'account-bills:export-run {token : 导出任务 token}';
protected $description = '执行店铺收款账户账单导出任务(后台进程调用)';
public function handle(AccountBillsExportTasks $tasks, AccountLedgerRemarkMap $remarkMap): int
{
$lock = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock('account-bills:export-worker', 10800);
if (! $lock->get()) return 75;
try {
return $this->runTask($tasks, $remarkMap);
} finally {
$lock->release();
}
}
private function runTask(AccountBillsExportTasks $tasks, AccountLedgerRemarkMap $remarkMap): int
{
$token = (string) $this->argument('token');
$task = $this->findTask($tasks, $token);
if ($task === null) {
$this->error('导出任务不存在:' . $token);
return self::FAILURE;
}
if (($task['status'] ?? '') !== 'queued') return self::SUCCESS;
$task = $tasks->patch($task, ['status' => 'running', 'started_at' => AccountBillsExportTasks::beijingNow()]);
try {
if (! empty($task['cancel'])) {
$tasks->patch($task, ['status' => 'cancelled', 'finished_at' => AccountBillsExportTasks::beijingNow()]);
$this->info('任务已取消。');
return self::SUCCESS;
}
$columns = array_values((array) ($task['columns'] ?? []));
if ($columns === []) {
throw new \RuntimeException('导出任务没有可用字段。');
}
$csv = fopen($tasks->csvPath($task), 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建导出文件。');
}
// 让「行数」列能显示真实百分比:镜像连接改为不缓冲取数,
// 行一取到就写盘,进度按 已写行数 / 总行数 实时刷新。
$this->streamRowsWithoutBuffering($remarkMap);
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_map(static fn (array $column): string => (string) ($column['label'] ?? ''), $columns));
$written = 0;
foreach ($this->rows($task, $remarkMap) as $row) {
fputcsv($csv, array_map(
static fn (array $column): string => self::cellValue($row, $column),
$columns,
));
$written++;
if ($written % 2000 === 0) {
// 用户在弹窗里点了「取消」:停止导出并删掉半成品文件。
$fresh = $tasks->find((int) $task['user_id'], (string) $task['token']);
if (! empty($fresh['cancel'])) {
fclose($csv);
@unlink($tasks->csvPath($task));
$tasks->patch($task, ['status' => 'cancelled', 'rows' => $written, 'finished_at' => AccountBillsExportTasks::beijingNow(), 'size' => 0]);
$this->info('任务已取消(已写入 ' . $written . ' 行后停止)。');
return self::SUCCESS;
}
$task = $tasks->patch($task, ['status' => 'running', 'rows' => $written]);
}
}
fclose($csv);
$task = $tasks->patch($task, [
'status' => 'done',
'rows' => $written,
'size' => (int) @filesize($tasks->csvPath($task)),
'finished_at' => AccountBillsExportTasks::beijingNow(),
'error' => null,
]);
$this->info('导出完成:' . $written . ' 行');
return self::SUCCESS;
} catch (Throwable $exception) {
$tasks->patch($task, [
'status' => 'failed',
'finished_at' => AccountBillsExportTasks::beijingNow(),
'error' => mb_substr($exception->getMessage(), 0, 200),
]);
$this->error('导出失败:' . $exception->getMessage());
return self::FAILURE;
}
}
private function findTask(AccountBillsExportTasks $tasks, string $token): ?array
{
$root = $tasks->rootDir();
foreach (glob($root . '/*/' . $token . '.json') ?: [] as $path) {
$task = json_decode((string) file_get_contents($path), true);
if (is_array($task) && ($task['token'] ?? null) === $token) {
return $task;
}
}
return null;
}
/** @return iterable<int,array<string,mixed>> */
private function rows(array $task, AccountLedgerRemarkMap $remarkMap): iterable
{
$tab = (string) ($task['tab'] ?? '');
$filters = (array) ($task['filters'] ?? []);
// 后台导出固定按「发生时间」取数:该列上有 (occurred_at, record_code) 联合索引,
// 数据库能按索引顺序边扫边返回,首行几百毫秒内到达,进度可实时刷新;
// 按「记录编码」走主键反向扫描过滤日期,要扫完整表才出首行,进度会长时间停在 0%。
$sort = 'occurred_at';
$dir = strtolower((string) ($filters['dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
unset($filters['sort'], $filters['dir'], $filters['export'], $filters['token'], $filters['total'], $filters['columns']);
$rows = match ($tab) {
'taobao-alipay' => app(StoreReceivingAlipayBillMirrorRepository::class)->exportRows($filters, $sort, $dir),
'taobao-wechat' => app(TaobaoWechatSettlementBillRepository::class)->exportAccountBills($filters, $sort, $dir),
'pinduoduo' => app(PinduoduoSettlementBillRepository::class)->exportAccountBills($filters, $sort, $dir),
'dewu' => app(DewuSettlementBillRepository::class)->exportAccountBills($filters, $sort, $dir),
'jd-transactions', 'jingxi' => JdBillPlatform::isJdBillTab($tab)
? app(JdDailyBillRepository::class)->forPlatform(JdBillPlatform::platformForBillType($tab))->exportRows($filters, $sort, $dir)
: [],
'jd-wallet' => app(StoreReceivingJdWalletBillRepository::class)->exportRows($filters, $sort, $dir),
'douyin' => app(StoreReceivingDouyinBillMirrorRepository::class)->exportRows($filters, $sort, $dir),
default => throw new \RuntimeException('该页签暂不支持后台导出:' . $tab),
};
return $remarkMap->decorateAll($rows, $tab);
}
/**
* 把镜像连接切成「不缓冲」并预热总账备注映射,使取数阶段逐批返回行。
*
* Web/CLI 默认用缓冲查询:整段结果集在数据库端算完才返回,进度会长时间停在 0%,
* 最后一次性跳到 100%。切到不缓冲后行随查询推进到达,进度即真实百分比。
*/
private function streamRowsWithoutBuffering(AccountLedgerRemarkMap $remarkMap): void
{
try {
// 预热:避免逐行装饰时中途再去读缓存/文件。
$remarkMap->map();
$connection = app(\Illuminate\Database\DatabaseManager::class)
->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if (! $connection instanceof \Illuminate\Database\MySqlConnection) {
return;
}
$pdo = $connection->getPdo();
$pdo->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
// 读连接与写连接是两套句柄,这里统一成同一个不缓冲句柄。
$connection->setReadPdo($pdo);
} catch (Throwable) {
// 拿不到句柄时退回默认行为,不影响导出结果。
}
}
/** @param array<string,mixed> $row */
private static function cellValue(array $row, array $column): string
{
$field = (string) ($column['field'] ?? '');
$value = str_starts_with($field, 'payload.')
? data_get($row['payload'] ?? [], substr($field, 8))
: data_get($row, $field);
$text = trim((string) ($value ?? ''));
if ($text === '') {
return '';
}
if (preg_match('/^\d{12,}$/', $text) === 1) {
return '="' . $text . '"';
}
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
}
}

View File

@ -1,112 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
use Symfony\Component\Process\Process;
use Throwable;
/**
* 科目映射表变更后的业绩明细自动重跑队列。
*
* 每轮按预算处理队列里的「平台 × 月份」;实际同步放在独立 PHP 子进程中,
* 避免大平台重跑时的内存累积,并让其它同步有机会取得全局锁。
*/
class RunAccountMappingResync extends Command
{
protected $signature = 'store-receipt:auto-resync {--max-jobs=1 : 每轮最多任务数,0为不限} {--max-seconds=300 : 单个任务结束后检查本轮时间预算}';
protected $description = '处理科目映射表变更触发的业绩明细重跑队列';
public function handle(AccountMappingResyncDispatcher $dispatcher): int
{
// 页面(Apache)与每分钟 cron 都以 www-data 运行,使用共享数据库锁,
// 不受 storage/framework/cache 文件属主影响,也能避免多容器重复消费。
$lock = Cache::store('upstream_shared')->lock('store-receipt:auto-resync', 86400);
if (! $lock->get()) {
// 已有进程在跑;本次新入队的任务会在当前进程下一轮循环或每分钟兜底时执行。
return self::SUCCESS;
}
try {
// 进程崩溃遗留的 running 任务超过 60 分钟视为可重试。
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('status', 'running')
->where('updated_at', '<', now()->subMinutes(60))
->update(['status' => 'pending', 'updated_at' => now()]);
$processed = 0;
$maxJobs = max(0, (int) $this->option('max-jobs'));
$deadline = microtime(true) + max(1, (int) $this->option('max-seconds'));
while (($maxJobs === 0 || $processed < $maxJobs) && microtime(true) < $deadline) {
$job = DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('status', 'pending')
->orderBy('id')
->first();
if ($job === null) {
break;
}
$processed++;
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('id', $job->id)
->where('status', 'pending')
->update([
'status' => 'running',
'attempts' => DB::raw('attempts + 1'),
'updated_at' => now(),
]);
try {
[$succeeded, $output] = $this->runSync($job);
// 同步期间又保存了映射时,dispatchFor 已把状态改回 pending;
// 这里只在仍为 running 时写 done,不能吞掉新请求。
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('id', $job->id)
->where('status', 'running')
->update([
'status' => $succeeded ? 'done' : 'failed',
'message' => mb_substr(
$output !== '' ? $output : ($succeeded ? 'OK' : '同步命令执行失败'),
0,
1000
),
'updated_at' => now(),
]);
} catch (Throwable $exception) {
Log::error('科目映射表自动重跑失败:' . $exception->getMessage());
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('id', $job->id)
->where('status', 'running')
->update([
'status' => 'failed',
'message' => mb_substr($exception->getMessage(), 0, 1000),
'updated_at' => now(),
]);
}
}
} finally {
$lock->release();
}
return self::SUCCESS;
}
/** @return array{bool,string} */
protected function runSync(object $job): array
{
$process = new Process([
PHP_BINARY ?: 'php', 'artisan', 'store-receipt:order-match:sync',
(string) $job->month, '--platform=' . $job->platform,
], base_path());
$process->setTimeout(7200);
$process->run();
return [$process->isSuccessful(), trim($process->getOutput() . "\n" . $process->getErrorOutput())];
}
}

View File

@ -155,18 +155,9 @@ class SyncBusinessMirrors extends Command
if (($state['status'] ?? '') === 'failed' && ! empty($state['last_attempted_at'])
&& now()->diffInSeconds(\Illuminate\Support\Carbon::parse($state['last_attempted_at'])) < 300) continue;
// Only explicitly opted-in small datasets receive periodic full reconciliation.
// Keep an interrupted full run in full mode so its cursor/generation can resume.
$hours = (int) $datasets->definitionValue($key, 'full_reconcile_hours', 0);
$lastFull = $state['last_full_succeeded_at'] ?? null;
$reconcile = $hours > 0 && (empty($lastFull)
|| \Illuminate\Support\Carbon::parse($lastFull)->addHours($hours)->lte(now()));
$resumeFull = $hours > 0 && ($state['run_mode'] ?? '') === 'full'
&& in_array($state['status'] ?? '', ['running', 'failed'], true);
$scheduled[] = [
'key' => $key,
'full' => $initial || $reconcile || $resumeFull || (bool) $this->option('full'),
'full' => $initial || (bool) $this->option('full'),
'catchup' => $initial || ($state['status'] ?? '') === 'running',
'last_attempted_at' => (string) ($state['last_attempted_at'] ?? ''),
];

View File

@ -1,60 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerRptSynchronizer;
use Throwable;
class SyncFinanceLedgerRpt extends Command
{
protected $signature = 'finance-ledger:sync-rpt
{--from= : 业务日期起,默认读取配置}
{--to= : 业务日期止,默认读取配置或当天}
{--days= : 只拉取最近 N 天并合并到现有明细}
{--merge : 合并到现有明细,不删除历史}
{--no-recalculate : 只同步明细,不重算流水}';
protected $description = '从 AI 库 rpt 同步财务台账动账明细,并重算每日流水';
public function handle(FinanceLedgerRptSynchronizer $synchronizer): int
{
$days = (int) ($this->option('days') ?: 0);
$from = $this->option('from') ?: null;
$merge = (bool) $this->option('merge');
if ($days > 0) {
$from = now()->subDays(max(1, $days) - 1)->toDateString();
$merge = true;
}
try {
$result = $synchronizer->sync(
! $this->option('no-recalculate'),
$from,
$this->option('to') ?: null,
$merge,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['明细行数', $result['rows']],
['收入合计', number_format($result['income'], 2)],
['支出合计', number_format($result['expense'], 2)],
['日期范围', $result['from'] . ' ~ ' . $result['to']],
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
['期末余额合计', isset($result['recalculated']) ? number_format($result['recalculated']['balance_sum'], 2) : '-'],
]);
$this->line('来源分布:');
foreach (($result['source_counts'] ?? []) as $source => $count) {
$this->line(' ' . $source . ': ' . $count);
}
$this->info('财务台账 rpt 同步完成。');
return self::SUCCESS;
}
}

View File

@ -1,182 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\JstOrderLabelItemCodes;
use Illuminate\Console\Command;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Throwable;
/**
* 把聚水潭商品明细(共享镜像)里的商品编码回填到「聚水潭订单标签明细」的货号列。
*
* 两个来源:
* - 主镜像 ops_jst_orders + ops_jst_order_item;
* - 全渠道镜像 ops_jst_qmcust_orders + ops_jst_qmcust_order_items。
*
* 页面/命令导入(jst-order-labels:import)已自带货号;本命令用于历史数据回填与修复,可反复执行。
*/
class SyncJstOrderLabelItemCodes extends Command
{
protected $signature = 'jst-order-labels:sync-item-codes
{--from= : 起始下单日期 YYYY-MM-DD(默认取标签明细里最早的下单日期)}
{--to= : 结束下单日期 YYYY-MM-DD(默认取标签明细里最晚的下单日期)}
{--company=1 : 公司 ID}
{--chunk=800 : 每批处理的订单数}';
protected $description = '按订单号把聚水潭商品明细里的货号回填到聚水潭订单标签明细';
/** 订单镜像 → 商品明细镜像。 */
private const SOURCES = [
['orders' => 'ops_jst_orders', 'items' => 'ops_jst_order_item'],
['orders' => 'ops_jst_qmcust_orders', 'items' => 'ops_jst_qmcust_order_items'],
];
public function handle(DatabaseManager $databases, JstOrderLabelItemCodes $itemCodes): int
{
$companyId = (int) $this->option('company');
$chunk = max((int) $this->option('chunk'), 100);
$prefix = $databases->connection()->getTablePrefix();
try {
[$from, $to] = $this->resolveWindow($databases, $companyId);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$start = $from . ' 00:00:00';
$end = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
$mirror = $databases->connection(config('upstream_mirrors.connection', 'business_mirror'));
$this->info(sprintf('同步货号:%s ~ %s(公司 %d)', $from, $to, $companyId));
$startedAt = microtime(true);
$totalUpdated = 0;
foreach (self::SOURCES as $source) {
if (! $mirror->getSchemaBuilder()->hasTable($source['orders']) || ! $mirror->getSchemaBuilder()->hasTable($source['items'])) {
$this->warn(sprintf('跳过 %s:镜像表不存在。', $source['orders']));
continue;
}
$scanned = 0;
$matched = 0;
$updated = 0;
$chunks = 0;
$mirrorName = $source['orders'];
try {
$mirror->table($source['orders'])
->select(['Id', 'o_id', 'so_id'])
->where('order_date', '>=', $start)
->where('order_date', '<', $end)
->orderBy('Id')
->chunkById($chunk, function ($records) use ($mirror, $itemCodes, $source, $prefix, $companyId, $start, $end, $mirrorName, &$scanned, &$matched, &$updated, &$chunks): void {
$chunks++;
$scanned += count($records);
$orderCodes = $itemCodes->forOrderIds($mirror, $source['items'], $records->pluck('o_id')->all());
$pairs = [];
foreach ($records as $record) {
$orderNo = trim((string) $record->so_id);
$codes = $orderCodes[(int) $record->o_id] ?? null;
if ($orderNo !== '' && $codes !== null && $codes !== '') {
$pairs[] = [$orderNo, $codes];
}
}
$matched += count($pairs);
if ($pairs !== []) {
$updated += $this->applyMap($prefix, $pairs, $companyId, $start, $end);
}
if ($chunks % 20 === 0) {
$this->line(sprintf(' …%s:已扫描 %d 单,匹配 %d,更新行 %d', $mirrorName, $scanned, $matched, $updated));
}
}, 'Id');
} catch (Throwable $exception) {
$this->error(sprintf('%s 同步失败:%s', $mirrorName, $exception->getMessage()));
return self::FAILURE;
}
$totalUpdated += $updated;
$this->info(sprintf('%s:扫描 %d 单,匹配 %d,更新标签行 %d', $mirrorName, $scanned, $matched, $updated));
}
$this->info(sprintf('完成:共更新标签行 %d,耗时 %.0f 秒。', $totalUpdated, microtime(true) - $startedAt));
return self::SUCCESS;
}
/**
* 一批订单的货号按订单号写回标签表(CASE 更新,命中 order_no 索引)。
*
* 订单号必须全程按字符串绑定:纯数字订单号一旦被 PHP 数组键或 PDO 当成整数,
* MariaDB 会把字符列 order_no 强转成数字比较,带「-」的订单号直接报 1292。
*
* @param list<array{0:string,1:string}> $pairs [[线上订单号, 货号], ...]
*/
private function applyMap(string $prefix, array $pairs, int $companyId, string $start, string $end): int
{
$cases = [];
$bindings = [];
$orderNos = [];
foreach ($pairs as [$orderNo, $codes]) {
$orderNo = (string) $orderNo;
$cases[] = 'WHEN ? THEN ?';
$bindings[] = $orderNo;
$bindings[] = $codes;
$orderNos[] = $orderNo;
}
$sql = 'UPDATE ' . $prefix . 'jst_order_labels SET item_code = CASE order_no ' . implode(' ', $cases) . ' END '
. 'WHERE company_id = ? AND order_no IN (' . implode(',', array_fill(0, count($orderNos), '?')) . ') '
. 'AND ordered_at >= ? AND ordered_at < ?';
$params = array_merge($bindings, [$companyId], $orderNos, [$start, $end]);
$updated = DB::connection()->update($sql, $params);
if ($updated > 0) {
app(\App\Business\PlantingOrderLabelLookup::class)->forget($companyId);
}
return $updated;
}
/** @return array{0:string,1:string} 起止日期(Y-m-d) */
private function resolveWindow(DatabaseManager $databases, int $companyId): array
{
$from = trim((string) $this->option('from'));
$to = trim((string) $this->option('to'));
if ($from === '' || $to === '') {
$row = $databases->table('jst_order_labels')
->where('company_id', $companyId)
->selectRaw('MIN(ordered_at) AS min_at, MAX(ordered_at) AS max_at')
->first();
if ($row === null || $row->min_at === null || $row->max_at === null) {
throw new RuntimeException('标签明细里没有数据,无法推断同步区间,请用 --from/--to 指定。');
}
$from = $from === '' ? substr((string) $row->min_at, 0, 10) : $from;
$to = $to === '' ? substr((string) $row->max_at, 0, 10) : $to;
}
foreach (['起始' => $from, '结束' => $to] as $label => $value) {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
throw new RuntimeException($label . '日期必须是 YYYY-MM-DD:' . $value);
}
}
if ($from > $to) {
throw new RuntimeException('起始日期不能晚于结束日期。');
}
return [$from, $to];
}
}

View File

@ -6,14 +6,12 @@ use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
use Modules\EcommerceFinance\Services\JushuitanCostPriceHistorySynchronizer;
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
use Throwable;
class SyncJushuitanCostPriceHistory extends Command
{
protected $signature = 'business-mirror:sync-jushuitan-cost-price-history
{--full : 强制启动或继续首次全量同步}
{--source=open_api : open_api 为正式操作历史;snapshot 为独立的镜像价格观测}
{--page-size= : 聚水潭接口每页读取条数,最大 50}
{--max-pages= : 本轮最多读取页数,0 表示直到完成}';
@ -27,25 +25,6 @@ class SyncJushuitanCostPriceHistory extends Command
return self::SUCCESS;
}
if ($this->option('source') === 'snapshot') {
try {
$result = app(JushuitanCostPriceSnapshotDiffer::class)->sync(
$this->option('page-size') === null ? 500 : (int) $this->option('page-size'),
$this->option('max-pages') === null ? 20 : (int) $this->option('max-pages'),
);
$this->info(sprintf('镜像价格观测:%s,本轮读取 %d 条,发现 %d 次价格变化;不代表完整操作历史。', $result['status'], $result['rows'], $result['changes']));
return self::SUCCESS;
} catch (Throwable $exception) {
Log::error('镜像价格观测失败。', ['exception' => $exception::class]);
$this->error('镜像价格观测失败,已保留基线、记录和续传游标。');
return self::FAILURE;
}
}
if ($this->option('source') !== 'open_api') {
$this->error('source 仅支持 open_api 或 snapshot。');
return self::FAILURE;
}
try {
$state = $mirror->state();
$full = $this->shouldRunFull($state);

View File

@ -1,78 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\PlantingWorkOrderSynchronizer;
use Illuminate\Console\Command;
use InvalidArgumentException;
use Throwable;
class SyncPlantingDetailsFromWorkOrders extends Command
{
protected $signature = 'planting-details:sync-work-orders
{--month= : 月份 YYYY-MM(与 --from/--to 二选一)}
{--from= : 起始日期 YYYY-MM-DD}
{--to= : 结束日期 YYYY-MM-DD}
{--company=1 : 公司 ID}
{--dry-run : 只统计不写库}
{--force-clear : 整月覆盖(连没有对应工单的人工行一起清掉)}';
protected $description = '把聚水潭「种菜报销」工单同步成种菜明细表(日期 = 工单完成日 − 1;按月重复执行不会重复写)';
public function handle(PlantingWorkOrderSynchronizer $synchronizer): int
{
try {
[$from, $to] = $this->window();
$result = $synchronizer->sync(
$from,
$to,
(int) $this->option('company'),
(bool) $this->option('dry-run'),
(bool) $this->option('force-clear'),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$unmatched = $result['unmatched_stores'];
$result['unmatched_stores'] = count($unmatched);
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
foreach (array_slice($unmatched, 0, 20, true) as $store => $count) {
$this->warn(sprintf('未匹配利润中心:%s(%d 行)', $store, $count));
}
if (count($unmatched) > 20) {
$this->warn('……还有 ' . (count($unmatched) - 20) . ' 家店铺未匹配到利润中心。');
}
return self::SUCCESS;
}
/** @return array{0:string,1:string} */
private function window(): array
{
$from = trim((string) $this->option('from'));
$to = trim((string) $this->option('to'));
$month = trim((string) $this->option('month'));
if ($month !== '') {
if ($from !== '' || $to !== '') {
throw new InvalidArgumentException('--month 与 --from/--to 只能给一个。');
}
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
throw new InvalidArgumentException('--month 必须是 YYYY-MM。');
}
return [$month . '-01', date('Y-m-t', strtotime($month . '-01'))];
}
if ($from === '' || $to === '') {
throw new InvalidArgumentException('请给 --month=YYYY-MM,或同时给 --from 与 --to。');
}
return [$from, $to];
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,921 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use InvalidArgumentException;
use Throwable;
/**
* 商品成本:以「业绩明细」(店铺收款账户账单 × 订单原始数据)的账单行为基数,
* 按「店铺 + 线上订单号」展开订单侧的商品行,带出发货仓库、仓库简称、商品单价和商品成本。
*
* 发货仓库取订单上的 wms_co_id 对应的聚水潭仓库名称;仓库简称查仓库映射表;
* 商品单价按「仓库简称 + 商品编码」取商品月加权平均单价里的加权平均价;商品成本 = 商品数量 × 商品单价。
*/
class SyncStoreReceiptProductCost extends Command
{
protected $signature = 'store-receipt:product-cost:sync
{month : 账期月份,格式 YYYY-MM}
{--platform= : 只重建某个平台页签,留空表示全部}
{--chunk=1000 : 临时表批量写入行数}
{--result-json= : 将结果统计和待核验清单写入指定 JSON 文件}';
protected $description = '展开业绩明细对应的订单商品行,计算商品成本(发货仓库 → 仓库简称 → 商品单价 → 商品成本)';
private const SOURCE_TABLE = 'store_receipt_order_match_rows';
private const TARGET_TABLE = 'store_receipt_product_cost_rows';
private const PRICE_TABLE = 'product_monthly_weighted_average_rows';
private const WAREHOUSE_MAPPING_TABLE = 'business_inventory_warehouse_name_mappings';
/** 虚拟条码(信誉订单/虚拟订单)不是真实商品,不计入商品成本。 */
private const VIRTUAL_SKU_PREFIX = 'X0000';
/** 套餐 / 服务及指定配件占位编码:不是实际成本商品,商品成本全局不取数。 */
private const EXCLUDED_SKUS = [
'TCXJ',
'FUWU',
'AZF',
'DF-PJZY0001',
'JIAXIANGJIANCAIJIAJUZIZHUANG964A',
];
/** 组合装条码前缀;条码结构为「前缀-组件条码-数量-组件条码-数量」。 */
private const COMBO_PREFIXES = ['ZH-', 'MKH-'];
/**
* 发货仓库黑名单:这些仓不是实际销售发货仓,整仓不参与商品成本;
* 例外:常州简尚家居有限公司里商品编码以 DF- 开头的行保留。
*/
private const EXCLUDED_WAREHOUSES = [
'常州简尚家居有限公司',
'简尚-苏州福利品仓',
'简尚-自动发货仓',
];
private const REMARK_NO_WAREHOUSE = '未匹配仓库简称';
private const REMARK_NO_PRICE = '未匹配商品单价';
/**
* 数量单位与单价口径不一致:定制配件这类明细行,聚水潭把配件尺寸/件数写进了「商品数量」
* (例如数量 9524、金额 2000.04,实际单价 0.21),再乘成品的月加权/代发单价会放大几百倍。
* 成本单价高于该行明细单价 3 倍以上时判定为口径不一致,成本留空不计入合计。
*/
private const REMARK_UNIT_MISMATCH = '数量与单价口径不一致';
private const UNIT_MISMATCH_FACTOR = 3.0;
/** 单价来源:月加权平均单价(首选)与线上代发价格(兜底)。 */
private const PRICE_SOURCE_WEIGHTED = '月加权平均单价';
private const PRICE_SOURCE_PURCHASE = '线上代发价格-采购单价';
private const PRICE_SOURCE_DING = '线上代发价格-代发单价';
/** 最后兜底:普通商品资料(operations_replica.rpt.jst_sku)里的成本价。 */
private const PRICE_SOURCE_CATALOG = '普通商品资料成本价';
/** 线上代发价格的采购单来源(与「线上代发价格」页面同一口径)。 */
private const PURCHASE_PRICE_TABLE = 'product_purchase_price_mirror_rows';
/** 线上代发价格的钉钉代发来源。 */
private const DING_DAIFA_TABLE = 'ding_online_daifa_price_mirror';
/**
* 订单侧两个来源:淘系(企微客户订单)与聚水潭订单。
* 两边的仓库字段不同:淘系明细没有 send_warehouse,用订单上的 wms_co_id;
* 聚水潭优先取明细行的 send_warehouse,为空时退回订单的 wms_co_id(与「订单原始数据」一致)。
*/
private const SOURCES = [
[
'key' => 'qmcust',
'orders' => 'ops_jst_qmcust_orders',
'items' => 'ops_jst_qmcust_order_items',
'warehouse' => 'o.wms_co_id',
],
[
'key' => 'jst',
'orders' => 'ops_jst_orders',
'items' => 'ops_jst_order_item',
'warehouse' => "COALESCE(NULLIF(i.send_warehouse, ''), o.wms_co_id)",
],
];
/**
* 账单页签 key → 业绩明细里的平台名:--platform 既接受页签 key(jingxi),
* 也接受平台名(京喜账单),避免命令行和页面口径对不上。
*/
private const PLATFORM_LABELS = [
'taobao-alipay' => '淘系支付宝',
'taobao-wechat' => '淘系微信',
'pinduoduo' => '拼多多',
'dewu' => '得物',
'jd-wallet' => '京东网银钱包',
'jd-transactions' => '京东账单',
'jingxi' => '京喜账单',
'douyin' => '抖音',
];
public function handle(): int
{
if ((int) ini_get('memory_limit') > 0) {
@ini_set('memory_limit', '2G');
}
$month = trim((string) $this->argument('month'));
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
throw new InvalidArgumentException('月份格式应为 YYYY-MM,例如 2026-08。');
}
$reportMonth = $month . '-01';
$platformInput = trim((string) $this->option('platform'));
$platform = self::PLATFORM_LABELS[$platformInput] ?? $platformInput;
$chunkSize = max((int) $this->option('chunk'), 100);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$mirror = DB::connection($connection);
// 账单侧取数:业绩明细里本月(可按平台限定)的账单行,一行 = 一个店铺的一个订单。
// 收入为 0 的补行没有对应的收款商品,不参与商品成本。
// 种菜金额非空,或新标签为「种菜报销 / 种菜未报销」的账单行整条排除。
$bills = $this->billRows($mirror, $reportMonth, $platform);
$this->info(sprintf(
'账期 %s%s:业绩明细账单行 %d 条',
$month,
$platform !== '' ? '(' . $platform . ')' : '',
$bills->count()
));
/** @var array<string,object> $billByCore 核心单号 → 账单行 */
$billByCore = [];
$candidates = [];
foreach ($bills as $bill) {
$core = self::cleanOrderNo((string) ($bill->order_core_no ?? ''));
if ($core === '') {
continue;
}
if (! isset($billByCore[$core])) {
$billByCore[$core] = $bill;
}
$candidates[$core] = true;
$raw = trim((string) ($bill->online_order_no ?? ''));
if ($raw !== '') {
$candidates[$raw] = true;
}
}
if ($billByCore === []) {
$this->warn('没有可匹配的账单单号,已清空本月数据。');
$this->pruneTarget($connection, $reportMonth, $platform);
return self::SUCCESS;
}
// 订单侧数据量大(企微 500 万单 / 聚水潭 900 万行),先用临时表收敛候选单号,
// 再走 so_id / o_id 索引取明细,避免逐批 IN 查询退化成全表扫描。
$mirror->statement('DROP TEMPORARY TABLE IF EXISTS tmp_cost_order_nos');
$mirror->statement('CREATE TEMPORARY TABLE tmp_cost_order_nos (no varchar(64) NOT NULL, PRIMARY KEY (no)) ENGINE=InnoDB');
foreach (array_chunk(array_keys($candidates), $chunkSize) as $batch) {
$mirror->table('tmp_cost_order_nos')->insertOrIgnore(
array_map(static fn (string $no): array => ['no' => $no], $batch)
);
}
$this->info('订单侧候选单号:' . count($candidates));
/** @var array<string,list<array{source:string,sku:string,qty:float,amount:float,warehouse:string,o_id:string,parent_o_id:string,row_key:string,status:string,shop_i_id:string,src_combine_sku_id:string}>> $lines */
$lines = [];
$excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'";
foreach (self::SOURCES as $source) {
try {
$rows = $mirror->select(
"SELECT '{$source['key']}' as source_key, "
. "o.so_id as so_id, "
. $source['warehouse'] . " as warehouse_code, "
. " COALESCE(wh.name, '') as warehouse_name, "
. " o.o_id as o_id, o.link_o_id as parent_o_id, "
. " CONCAT(o.o_id, '-', i.oi_id) as row_key, "
. " i.sku_id as sku_id, i.qty as qty, i.amount as amount, o.status as status, "
. " i.shop_i_id as shop_i_id, i.src_combine_sku_id as src_combine_sku_id
FROM tmp_cost_order_nos t
STRAIGHT_JOIN {$source['orders']} o ON o.so_id = t.no
STRAIGHT_JOIN {$source['items']} i ON i.o_id = o.o_id
LEFT JOIN ops_jst_warehouse_info wh ON wh.wms_co_id = o.wms_co_id
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'
AND UPPER(TRIM(COALESCE(i.sku_id, ''))) NOT IN ({$excludedSkuSql})"
);
} catch (Throwable $exception) {
$this->error('订单明细查询失败(' . $source['items'] . '):' . $exception->getMessage());
return self::FAILURE;
}
foreach ($rows as $row) {
$core = self::cleanOrderNo((string) $row->so_id);
if ($core === '' || ! isset($billByCore[$core])) {
continue;
}
$sku = trim((string) $row->sku_id);
if ($sku === '') {
continue;
}
$qty = (float) $row->qty;
$amount = (float) $row->amount;
// 数量与金额都为 0 的行是拆单/换货留下的占位行,不是真实发货商品。
if ($qty == 0.0 && $amount == 0.0) {
continue;
}
$warehouse = trim((string) $row->warehouse_name);
if ($warehouse === '') {
$warehouse = trim((string) ($row->warehouse_code ?? ''));
// 订单上的 wms_co_id 为 0 表示没有仓库信息,不能当成仓库名。
if ($warehouse === '0') {
$warehouse = '';
}
}
// 发货仓库过滤(在商品编码全局排除之后执行):
// 默认排除常州简尚家居有限公司 / 简尚-苏州福利品仓 / 简尚-自动发货仓;
// 例外:常州简尚家居有限公司里「DF-」开头的商品行保留。
if (in_array($warehouse, self::EXCLUDED_WAREHOUSES, true)) {
$isDfException = $warehouse === '常州简尚家居有限公司'
&& str_starts_with(mb_strtoupper($sku), 'DF-');
if (! $isDfException) {
continue;
}
}
$lines[$core][] = [
'source' => (string) $row->source_key,
// 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。
'sku' => mb_strtoupper($sku),
'qty' => $qty,
'amount' => $amount,
'warehouse' => $warehouse,
// 拆单层级:o_id 与上级 link_o_id,用于只保留末级发货行(叶子行)。
'o_id' => (string) $row->o_id,
'parent_o_id' => (string) ($row->parent_o_id ?? ''),
'status' => (string) ($row->status ?? ''),
'row_key' => (string) $row->row_key,
'shop_i_id' => trim((string) ($row->shop_i_id ?? '')),
'src_combine_sku_id' => mb_strtoupper(trim((string) ($row->src_combine_sku_id ?? ''))),
];
}
}
$this->info('订单侧命中核心单号:' . count($lines));
$shortNames = $this->warehouseShortNames();
$prices = $this->weightedAverageUnitPrices($mirror, $reportMonth);
// 月加权平均单价匹配不到时,用「线上代发价格」(采购单价 / 钉钉代发单价)兜底。
$fallbackPrices = $this->onlineDaifaUnitPrices($mirror);
// 代发价格也没有时,再用普通商品资料里的商品成本价兜底。
$catalogPrices = $this->catalogCostPrices($mirror);
$this->info(sprintf(
'仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d,商品资料成本价条目:%d',
count($shortNames['by_provider']),
count($prices),
count($fallbackPrices),
count($catalogPrices)
));
$this->pruneTarget($connection, $reportMonth, $platform);
$target = DB::connection($connection)->table(self::TARGET_TABLE);
$now = now();
$buffer = [];
$imported = 0;
$quantityTotal = 0.0;
$costTotal = 0.0;
$missingPriceRows = 0;
$missingWarehouseRows = 0;
$fallbackPriceRows = 0;
$catalogPriceRows = 0;
$mismatchRows = 0;
$matchedPriceRows = 0;
$placeholderExcludedRows = 0;
$bundleParentExcludedRows = 0;
$pendingCostRows = [];
foreach ($billByCore as $core => $bill) {
// 展示单号与业绩明细完全一致;$core 只用于订单侧清洗后匹配。
$displayOrderNo = (string) ($bill->online_order_no ?? '');
if ($displayOrderNo === '') {
$displayOrderNo = $core;
}
$excludedPlaceholders = 0;
$excludedBundleParents = 0;
$costLines = $this->costLines(
$lines[$core] ?? [],
$excludedPlaceholders,
$excludedBundleParents,
);
$placeholderExcludedRows += $excludedPlaceholders;
$bundleParentExcludedRows += $excludedBundleParents;
foreach ($costLines as $line) {
$short = $shortNames['by_provider'][$line['warehouse']] ?? '';
$price = $short !== '' ? ($prices[$short . "\0" . $line['sku']] ?? null) : null;
$priceSource = $price === null ? null : self::PRICE_SOURCE_WEIGHTED;
// 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。
if ($price === null) {
$fallback = $fallbackPrices[$line['sku']] ?? null;
if ($fallback !== null) {
$price = $fallback['price'];
$priceSource = $fallback['source'];
$fallbackPriceRows++;
}
}
// 线上代发价格也没有时,最后用普通商品资料的商品成本价兜底。
if ($price === null) {
$catalogPrice = $catalogPrices[$line['sku']] ?? null;
if ($catalogPrice !== null) {
$price = $catalogPrice;
$priceSource = self::PRICE_SOURCE_CATALOG;
$catalogPriceRows++;
}
}
$quantity = round($line['qty'], 4);
$unitPrice = $price === null ? null : round((float) $price, 2);
$cost = $unitPrice === null ? null : round($quantity * $unitPrice, 2);
// 数量单位与单价口径校验:该行明细单价 = 明细金额 ÷ 数量。
// 成本单价高于明细单价 3 倍以上说明两边单位不是一回事(定制配件行),成本留空。
$detailUnitPrice = $quantity > 0 ? (float) $line['amount'] / $quantity : 0.0;
$unitMismatch = $unitPrice !== null
&& $detailUnitPrice > 0
&& $unitPrice > $detailUnitPrice * self::UNIT_MISMATCH_FACTOR;
if ($unitMismatch) {
$cost = null;
$unitPrice = null;
$priceSource = null;
}
$remarks = [];
if ($unitMismatch) {
$remarks[] = self::REMARK_UNIT_MISMATCH;
$mismatchRows++;
}
if ($short === '') {
$remarks[] = self::REMARK_NO_WAREHOUSE;
$missingWarehouseRows++;
}
if ($unitPrice === null) {
if (! $unitMismatch) {
$remarks[] = self::REMARK_NO_PRICE;
$missingPriceRows++;
$pendingCostRows[] = [
'platform' => (string) $bill->platform,
'store_name' => (string) $bill->store_name,
'online_order_no' => $displayOrderNo,
'warehouse' => $line['warehouse'] !== '' ? $line['warehouse'] : null,
'warehouse_short_name' => $short !== '' ? $short : null,
'product_code' => $line['sku'],
'product_quantity' => $quantity,
'reason' => $short === ''
? '未匹配仓库简称'
: '仓库简称 + 商品编码在月加权、线上代发、普通商品资料中均无有效单价',
'code_type' => $this->pendingCodeType((string) $line['sku']),
];
}
} else {
$matchedPriceRows++;
$quantityTotal += $quantity;
$costTotal += (float) $cost;
}
$buffer[] = [
'report_month' => $reportMonth,
'row_key' => md5($reportMonth . '|' . $bill->platform . '|' . $bill->store_name . '|' . $core . '|' . $line['warehouse'] . '|' . $line['sku']),
'platform' => $bill->platform,
'occurred_date' => $bill->occurred_date,
'store_name' => $bill->store_name,
'online_order_no' => $displayOrderNo,
'warehouse' => $line['warehouse'] !== '' ? $line['warehouse'] : null,
'warehouse_short_name' => $short !== '' ? $short : null,
'product_code' => $line['sku'],
'product_quantity' => $quantity,
'unit_price' => $unitPrice,
'price_source' => $priceSource,
'product_cost' => $cost,
'remark' => $remarks === [] ? null : implode(' / ', $remarks),
'imported_at' => $now,
];
if (count($buffer) >= 500) {
$target->insertOrIgnore($buffer);
$imported += count($buffer);
$buffer = [];
}
}
}
if ($buffer !== []) {
$target->insertOrIgnore($buffer);
$imported += count($buffer);
}
$this->info(sprintf('写入完成,共 %d 行。', $imported));
$this->info(sprintf(' 商品数量合计:%s', number_format($quantityTotal, 2)));
$this->info(sprintf(' 商品成本合计:%s', number_format($costTotal, 2)));
$this->info(sprintf(
' 未匹配仓库简称:%d 行;线上代发价格兜底:%d 行;商品资料成本价兜底:%d 行;仍未匹配商品单价:%d 行;数量与单价口径不一致(不计成本):%d 行',
$missingWarehouseRows,
$fallbackPriceRows,
$catalogPriceRows,
$missingPriceRows,
$mismatchRows
));
$pendingCostQuantity = array_sum(array_column($pendingCostRows, 'product_quantity'));
$this->info(sprintf(
' 正常匹配单价:%d 行;排除平台占位行:%d 行;排除组合装主行:%d 行;待核验成本:%d 行 / %s 件',
$matchedPriceRows,
$placeholderExcludedRows,
$bundleParentExcludedRows,
$missingPriceRows,
number_format((float) $pendingCostQuantity, 2),
));
$result = [
'status' => 'success',
'report_month' => $reportMonth,
'platform' => $platform !== '' ? $platform : '全部',
'source_bill_rows' => $bills->count(),
'candidate_orders' => count($candidates),
'matched_orders' => count($lines),
'written_rows' => $imported,
'normal_matched_rows' => $matchedPriceRows,
'placeholder_excluded_rows' => $placeholderExcludedRows,
'bundle_parent_excluded_rows' => $bundleParentExcludedRows,
'excluded_rows' => $placeholderExcludedRows + $bundleParentExcludedRows,
'pending_cost_rows' => $missingPriceRows,
'pending_cost_quantity' => round((float) $pendingCostQuantity, 4),
'unit_mismatch_rows' => $mismatchRows,
'missing_warehouse_rows' => $missingWarehouseRows,
'fallback_purchase_price_rows' => $fallbackPriceRows,
'fallback_catalog_price_rows' => $catalogPriceRows,
'matched_quantity' => round($quantityTotal, 4),
'matched_cost' => round($costTotal, 2),
'pending_cost' => $pendingCostRows,
'generated_at' => now()->toDateTimeString(),
];
$resultPath = trim((string) $this->option('result-json'));
if ($resultPath !== '') {
File::ensureDirectoryExists(dirname($resultPath));
File::put(
$resultPath,
json_encode(
$result,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
) . PHP_EOL,
);
}
return self::SUCCESS;
}
/**
* @return \Illuminate\Support\Collection<int,object>
*/
private function billRows($mirror, string $reportMonth, string $platform)
{
$query = $mirror->table(self::SOURCE_TABLE)
->where('report_month', $reportMonth)
->whereNotNull('income_amount')
->where('income_amount', '!=', 0)
// planting_amount 是旧字段兼容:任何非空值都表示该订单命中了种菜单。
->whereNull('planting_amount')
// 新版业绩明细把两类种菜状态合并为标签;任一种菜标签都不参与商品成本。
->whereNull('planting_label');
if ($platform !== '') {
$query->where('platform', $platform);
}
return $query->get([
'platform', 'store_name', 'order_core_no', 'online_order_no', 'occurred_date',
]);
}
private function pendingCodeType(string $code): string
{
$code = mb_strtoupper(trim($code));
if (preg_match('/^\d+$/', $code) === 1) {
return '平台数字编码未映射内部商品';
}
if (str_starts_with($code, '运营组合套装')) {
return '运营组合装未找到可计价组件';
}
if (str_starts_with($code, 'MKH-') || str_starts_with($code, 'ZH-')) {
return '组合装编码不完整或无法拆分';
}
if (str_starts_with($code, 'DF-')) {
return '配件编码缺少成本价';
}
if (str_contains($code, '+')) {
return '多个商品编码拼接未拆分';
}
return '内部商品编码缺少有效价格';
}
/**
* 拆单取数:聚水潭拆单后,主单/中间汇总单(被其他行 link 到的 o_id)只是汇总记录,
* 最末级订单行(没有任何下级)才是实际发货行。
*
* 取数规则:
* 1) 按「o_id + oi_id + 商品编码」去掉完全重复行;
* 2) 只保留叶子行——凡是被同一订单其他行 link_o_id 引用过的 o_id 全部剔除,
* 避免主单、中间汇总层与末级发货行重复计数;
* 3) 订单内只要存在带仓库的行,就丢弃空仓库的汇总/占位行;
* 如果带仓库的行全部是 Cancelled,则整单不参与商品成本;
* 4) 排除 Cancelled 行,同一订单的多条末级行按「发货仓库 + 商品编码」汇总数量。
*
* @param list<array{source:string,sku:string,qty:float,amount:float,warehouse:string,o_id:string,parent_o_id:string,row_key:string,status:string,shop_i_id:string,src_combine_sku_id:string}> $rows
* @return list<array{sku:string,qty:float,amount:float,warehouse:string}>
*/
private function costLines(
array $rows,
?int &$placeholderExcludedRows = null,
?int &$bundleParentExcludedRows = null,
): array
{
$placeholderExcludedRows = 0;
$bundleParentExcludedRows = 0;
if ($rows === []) {
return [];
}
// 1) 先按订单明细主键去掉上游的完全重复行(同一 o_id + oi_id 只算一次)。
$unique = [];
foreach ($rows as $row) {
$key = $row['source'] . "\0" . $row['row_key'] . "\0" . $row['sku'];
$unique[$key] = $row;
}
// 2) 组合装拆分后的组件会记录 src_combine_sku_id。源订单上的组合装主行
// 只是汇总占位,不能与组件同时计入成本。
$bundleParents = [];
foreach ($unique as $row) {
$parentSku = mb_strtoupper(trim((string) ($row['src_combine_sku_id'] ?? '')));
if ($parentSku !== '') {
$bundleParents[$row['source'] . "\0" . $row['o_id'] . "\0" . $parentSku] = true;
}
}
if ($bundleParents !== []) {
foreach ($unique as $key => $row) {
if (isset($bundleParents[$row['source'] . "\0" . $row['o_id'] . "\0" . $row['sku']])) {
$bundleParentExcludedRows++;
unset($unique[$key]);
}
}
}
// 3) 平台原始商品编码通常是一长串数字。同一订单、同一 shop_i_id
// 如果已经有内部商品编码,则数字行只是平台占位,不参与成本。
$internalSiblings = [];
foreach ($unique as $row) {
$shopItemId = trim((string) ($row['shop_i_id'] ?? ''));
if ($shopItemId !== '' && preg_match('/^\d+$/', (string) $row['sku']) !== 1) {
$internalSiblings[$row['source'] . "\0" . $row['o_id'] . "\0" . $shopItemId] = true;
}
}
if ($internalSiblings !== []) {
foreach ($unique as $key => $row) {
$shopItemId = trim((string) ($row['shop_i_id'] ?? ''));
if (
$shopItemId !== ''
&& preg_match('/^\d+$/', (string) $row['sku']) === 1
&& isset($internalSiblings[$row['source'] . "\0" . $row['o_id'] . "\0" . $shopItemId])
) {
$placeholderExcludedRows++;
unset($unique[$key]);
}
}
}
// 4) 组合装条码(ZH-/MKH-条码-数量-条码-数量)按子订单拆成组件行:
// 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。
// 解析不了的组合条码(条码被截断等)保持原样,避免丢行。
$expanded = [];
foreach ($unique as $row) {
foreach (self::expandComboSku((string) $row['sku'], (float) $row['qty']) as [$code, $codeQty, $isComponent]) {
$expanded[] = array_merge($row, [
'sku' => mb_strtoupper($code),
'qty' => $codeQty,
// 组合装明细金额对应整包,拆到组件后不再参与数量×单价校验。
'amount' => $isComponent ? 0.0 : (float) $row['amount'],
]);
}
}
$unique = $expanded;
if ($unique === []) {
return [];
}
// 5) 只保留叶子行:o_id 没有被同组其他行的 link_o_id 引用的,才是实际发货行。
$parentIds = [];
foreach ($unique as $row) {
$parent = trim((string) ($row['parent_o_id'] ?? ''));
if ($parent !== '' && $parent !== '0') {
$parentIds[$parent] = true;
}
}
if ($parentIds !== []) {
$leaves = array_filter(
$unique,
static fn (array $row): bool => ! isset($parentIds[trim((string) $row['o_id'])])
);
// 兜底:理论上不会出现全部被过滤(除非数据自相矛盾),真有则保留原集合避免整单丢失。
if ($leaves !== []) {
$unique = $leaves;
}
}
// 6) 组内只要出现带仓库的行,空仓库行就是汇总/占位行,直接丢弃。
$hasWarehouse = false;
foreach ($unique as $row) {
if (trim((string) $row['warehouse']) !== '') {
$hasWarehouse = true;
break;
}
}
if ($hasWarehouse) {
$unique = array_filter(
$unique,
static fn (array $row): bool => trim((string) $row['warehouse']) !== ''
);
}
// 7) 排除已取消行;若带仓库行全部取消,上面已把空仓库汇总行丢掉,这里结果为空 → 整单不计。
$unique = array_filter(
$unique,
static fn (array $row): bool => strtoupper(trim((string) ($row['status'] ?? ''))) !== 'CANCELLED'
);
// 8) 汇总到「发货仓库 + 商品编码」。
$aggregated = [];
foreach ($unique as $row) {
$key = $row['warehouse'] . "\0" . $row['sku'];
$aggregated[$key] = [
'sku' => $row['sku'],
'warehouse' => $row['warehouse'],
'qty' => ($aggregated[$key]['qty'] ?? 0.0) + $row['qty'],
'amount' => ($aggregated[$key]['amount'] ?? 0.0) + $row['amount'],
];
}
return array_values($aggregated);
}
/**
* 组合装条码拆组件:聚水潭的组合装 sku 形如
* ZH-<条码>-<数量>[-<条码>-<数量>...]
* MKH-<条码>-<数量>[-<条码>-<数量>...]
* 条码本身含横杠,所以按「纯数字段」识别数量,再按组件展开。
*
* 返回 [条码, 数量, 是否组件];非组合装或解析失败时返回原条码 / 原数量。
*
* @return list<array{0:string,1:float,2:bool}>
*/
private static function expandComboSku(string $sku, float $quantity): array
{
$prefix = null;
foreach (self::COMBO_PREFIXES as $candidate) {
if (str_starts_with(mb_strtoupper($sku), $candidate)) {
$prefix = $candidate;
break;
}
}
if ($prefix === null) {
return [[$sku, $quantity, false]];
}
$parts = explode('-', substr($sku, strlen($prefix)));
$components = [];
$buffer = [];
foreach ($parts as $part) {
if ($part === '') {
return [[$sku, $quantity, false]];
}
if (preg_match('/^\d+(\.\d+)?$/', $part) === 1) {
if ($buffer === []) {
return [[$sku, $quantity, false]];
}
$components[] = [implode('-', $buffer), (float) $part];
$buffer = [];
continue;
}
$buffer[] = $part;
}
if ($buffer !== [] || $components === []) {
return [[$sku, $quantity, false]];
}
$expanded = [];
foreach ($components as [$code, $multiplier]) {
// 组合装里如果拆出套餐/服务占位编码,同样不取数。
if (in_array(mb_strtoupper(trim($code)), self::EXCLUDED_SKUS, true)) {
continue;
}
$expanded[] = [$code, $quantity * $multiplier, true];
}
return $expanded === [] ? [] : $expanded;
}
/**
* 仓库映射表:发货仓库 → 仓库简称。
*
* 映射表按「仓储方 + 仓库」登记,仓库名带「主仓」的那条代表该仓储方的正式出货仓,
* 因此优先用它作为发货仓库对应的简称;同时保留「完整仓库名 → 简称」的精确匹配。
*
* @return array{by_provider:array<string,string>,by_warehouse:array<string,string>}
*/
private function warehouseShortNames(): array
{
$byProvider = [];
foreach (DB::table(self::WAREHOUSE_MAPPING_TABLE)
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']) as $mapping) {
$provider = trim((string) $mapping->warehouse_provider);
$warehouse = trim((string) $mapping->warehouse);
$short = trim((string) $mapping->warehouse_short_name);
if ($short === '') {
continue;
}
if ($provider === '') {
continue;
}
// 只认「仓储方 + 仓库含主仓」的那条:发货仓库是仓储方级别的名称,
// 次品仓 / 销退仓 / 客退仓这些行的简称(多为「-不看」)不能套到主仓发货上。
$isMain = str_contains($warehouse, '主仓');
if ($isMain) {
$byProvider[$provider] = $short;
continue;
}
// 映射表没有主仓行时,仅接受「仓库名 = 仓储方名」这种同名仓库(如重庆渝北仓),
// 其余(次品仓、配件单仓等)不再兜底,避免把主仓订单误判成不看仓。
if ($warehouse === $provider && ! isset($byProvider[$provider])) {
$byProvider[$provider] = $short;
continue;
}
// 业务确认:常州简尚家居有限公司的发货仓对应映射表里的「配件单仓」行
// (仓储方=常州简尚家居有限公司 / 仓库=配件单仓 → 配件单仓-不看)。
if (str_contains($warehouse, '配件单仓') && ! isset($byProvider[$provider])) {
$byProvider[$provider] = $short;
}
}
return ['by_provider' => $byProvider];
}
/**
* 商品月加权平均单价:按「仓库简称 + 商品编码」取加权平均价。
*
* @return array<string,float> 键为「仓库简称\0商品编码」
*/
private function weightedAverageUnitPrices($mirror, string $reportMonth): array
{
$prices = [];
$query = $mirror->table(self::PRICE_TABLE)
->where('report_month', $reportMonth)
->whereNotNull('weighted_average_unit_price');
foreach ($query->cursor() as $row) {
$prices[trim((string) $row->warehouse_short_name) . "\0" . mb_strtoupper(trim((string) $row->product_code))]
= (float) $row->weighted_average_unit_price;
}
return $prices;
}
/**
* 线上代发价格:月加权平均单价匹配不到时的兜底单价(按商品编码)。
*
* 与「线上代发价格」页面同源,取两个来源:
* 1. 采购单管理(product_purchase_price_mirror_rows):按商品编码取最新一次采购单价;
* 2. 钉钉线上代发审批(ding_online_daifa_price_mirror):商品ID 关联商品目录拿到商品编码,
* 单价 = 代发订单金额 ÷ 订单发货数量。
*
* 同一商品编码两边都有时取采购单价(本身就是单价字段,口径更直接)。
*
* @return array<string,array{price:float,source:string}>
*/
private function onlineDaifaUnitPrices($mirror): array
{
$prices = [];
try {
// 采购单管理:同一商品分多次采购时,后发生的记录覆盖先发生的,最终留下最新采购单价。
$purchaseRows = $mirror->table(self::PURCHASE_PRICE_TABLE)
->whereIn('document_status', ['Confirmed', 'Finished'])
->where('unit_price', '>', 0)
->orderByRaw('COALESCE(finished_at, document_created_at, purchase_updated_at, created_at)')
->get(['sku_id', 'unit_price']);
foreach ($purchaseRows as $row) {
$sku = mb_strtoupper(trim((string) $row->sku_id));
if ($sku !== '') {
$prices[$sku] = [
'price' => round((float) $row->unit_price, 2),
'source' => self::PRICE_SOURCE_PURCHASE,
];
}
}
} catch (Throwable $exception) {
$this->warn('采购单价格读取失败(跳过该兜底来源):' . $exception->getMessage());
}
try {
// 钉钉线上代发:镜像只存商品ID(= 聚水潭 shop_i_id),需要经商品目录换成商品编码。
$daifaRows = $mirror->table(self::DING_DAIFA_TABLE)
->where('ship_qty', '>', 0)
->where('daifa_amount', '>', 0)
->orderBy('workflow_created_at')
->get(['product_id', 'ship_qty', 'daifa_amount']);
$productIds = [];
foreach ($daifaRows as $row) {
$productId = trim((string) $row->product_id);
// 商品ID 是聚水潭的数字ID:审批表里偶尔填了文字(如「详情参考表格」),
// 混进 IN 条件会让 bigint 列做隐式类型转换、索引失效,这里只保留纯数字。
if ($productId !== '' && ctype_digit($productId)) {
$productIds[$productId] = true;
}
}
/** @var array<string,string> $skuByProductId */
$skuByProductId = [];
// 注意:shop_i_id 是 varchar,PHP 会把「纯数字字符串」的数组键转成 int,
// 直接传进去会让 MariaDB 做隐式类型转换、索引失效(实测 2500 万行全表扫)。
// 这里显式转字符串再绑定。
foreach (array_chunk(array_map('strval', array_keys($productIds)), 500) as $batch) {
foreach ($mirror->table('ops_jst_sku_map')->whereIn('shop_i_id', $batch)
->get(['shop_i_id', 'sku_id']) as $row) {
$sku = mb_strtoupper(trim((string) $row->sku_id));
if ($sku !== '') {
$skuByProductId[(string) $row->shop_i_id] = $sku;
}
}
}
foreach ($daifaRows as $row) {
$sku = $skuByProductId[trim((string) $row->product_id)] ?? '';
$quantity = (float) $row->ship_qty;
if ($sku === '' || $quantity <= 0 || isset($prices[$sku])) {
continue;
}
$prices[$sku] = [
'price' => round((float) $row->daifa_amount / $quantity, 2),
'source' => self::PRICE_SOURCE_DING,
];
}
} catch (Throwable $exception) {
$this->warn('线上代发价格读取失败(跳过该兜底来源):' . $exception->getMessage());
}
return $prices;
}
/**
* 普通商品资料(operations_replica.rpt.jst_sku → ops_jst_sku)里的商品成本价:
* 只取普通商品(sku_type=normal)且成本价大于 0 的记录,按商品编码建索引。
*
* @return array<string,float>
*/
private function catalogCostPrices($mirror): array
{
$prices = [];
try {
foreach ($mirror->table('ops_jst_sku')
->where('sku_type', 'normal')
->where('cost_price', '>', 0)
->get(['sku_id', 'cost_price']) as $row) {
$sku = mb_strtoupper(trim((string) $row->sku_id));
if ($sku !== '') {
$prices[$sku] = round((float) $row->cost_price, 2);
}
}
} catch (Throwable $exception) {
$this->warn('普通商品资料成本价读取失败(跳过该兜底来源):' . $exception->getMessage());
}
return $prices;
}
private function pruneTarget(string $connection, string $reportMonth, string $platform): void
{
$query = DB::connection($connection)->table(self::TARGET_TABLE)->where('report_month', $reportMonth);
if ($platform !== '') {
$query->where('platform', $platform);
}
$query->delete();
}
/**
* 订单号清洗沿用业绩明细的同一套口径(去日期前缀 / 空格 / 各类横杠,统一大写)。
*/
private static function cleanOrderNo(string $value): string
{
return SyncStoreReceiptOrderMatches::cleanOrderNo($value);
}
}

View File

@ -38,7 +38,7 @@ class SyncStoreReportMirror extends Command
}
$this->info(sprintf(
'店铺报表镜像:流程期间 %s/%d 行,主体变更 %s(检查 %d 店,新增 %d 条),账单 %s/%d 行,日汇总 %s/%d 行,销量汇总 %s/%d 行。',
'店铺报表镜像:流程期间 %s/%d 行,主体变更 %s(检查 %d 店,新增 %d 条),账单 %s/%d 行,日汇总 %s/%d 行。',
$result['lifecycles']['status'],
$result['lifecycles']['source_rows'],
$result['period_changes']['status'],
@ -48,8 +48,6 @@ class SyncStoreReportMirror extends Command
$result['bills']['source_rows'],
$result['daily']['status'],
$result['daily']['source_rows'],
$result['paid_sales']['status'],
$result['paid_sales']['stored_rows'],
));
return self::SUCCESS;

View File

@ -1,51 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
use Throwable;
/**
* 店铺维度报表的「销量」汇总:业绩明细「收入金额 + 优惠金额 + 冲减金额 + 退款金额」
* 按「店铺名称 + 发生日期」预汇总。
*
* 业绩明细同步(store-receipt:order-match:sync)结束后会自动刷新一次;
* 这个命令用于单独重跑(例如补数据或按最新代码强制重建)。
*/
class SyncStoreReportPaidSales extends Command
{
protected $signature = 'business-mirror:sync-store-report-paid-sales
{--force : 忽略来源指纹,强制重建销量汇总}';
protected $description = '把业绩明细的收入/优惠/冲减/退款金额按店铺和发生日期汇总,供店铺维度报表销量列读取';
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
{
try {
$result = $synchronizer->syncPaidSales((bool) $this->option('force'));
} catch (Throwable $exception) {
$this->error('店铺维度报表销量汇总失败:' . $exception->getMessage());
return self::FAILURE;
}
if ($result['status'] === 'skipped') {
$this->warn('镜像里还没有业绩明细表,销量汇总未执行。');
return self::SUCCESS;
}
if ($result['status'] !== 'succeeded') {
$this->error('店铺维度报表销量汇总失败,页面继续读取上一版数据。');
return self::FAILURE;
}
$this->info($result['changed']
? sprintf('销量汇总已重建:业绩明细 %d 行 → 汇总 %d 行。', $result['source_rows'], $result['stored_rows'])
: sprintf('业绩明细没有变化,沿用当前销量汇总(%d 行)。', $result['stored_rows']));
return self::SUCCESS;
}
}

View File

@ -2,7 +2,6 @@
namespace App\Console\Commands;
use App\Business\StoreViolationAlertCalculator;
use App\Business\StoreViolationMapper;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
@ -24,11 +23,7 @@ class SyncStoreViolations extends Command
protected $description = '同步店铺违规信息(operations_replica.rpt.crawl_shop_violation → store_violation_records)';
public function handle(
StoreViolationMapper $mapper,
StoreViolationRecordRepository $repository,
StoreViolationAlertCalculator $calculator,
): int
public function handle(StoreViolationMapper $mapper, StoreViolationRecordRepository $repository): int
{
if (! $repository->available()) {
$this->error('统一表 store_violation_records 尚未建立,请先执行数据库迁移。');
@ -73,32 +68,11 @@ class SyncStoreViolations extends Command
$skipped = 0;
$read = 0;
// 违规原因/处置方式:上游视图补齐后自动参与同步;未提供时不触碰这两列,
// 避免每日同步把导入补充的数据清空。
$extraColumns = $this->availableExtraColumns($pdo);
$updateColumns = $extraColumns === []
? StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS
: array_merge(StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS, array_keys($extraColumns));
// 预警派生字段(级别/责任人/处理截止等)每次都随同步重算写入。
$updateColumns = array_values(array_unique(array_merge(
$updateColumns,
StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS,
)));
try {
while (true) {
// FreeTDS 可能按连接区域返回 date(例如 "Oct 4 2026 12:00:00:AM"),
// 与镜像同步器保持一致:在源端转成 ISO 字符串再交给 PHP 解析。
$selectedColumns = ' id, shop_name, violation_no,'
. ' CONVERT(varchar(10), violation_date, 23) AS violation_date,'
. ' CONVERT(varchar(10), deadline, 23) AS deadline,'
. ' violation_content, status, window_name, platform,'
. ' CONVERT(varchar(10), crawl_date, 23) AS crawl_date';
foreach ($extraColumns as $target => $sourceColumn) {
$selectedColumns .= ', ' . $sourceColumn . ' AS ' . $target;
}
$sql = 'SELECT TOP ' . $batchSize . $selectedColumns
$sql = 'SELECT TOP ' . $batchSize
. ' id, shop_name, violation_no, violation_date, deadline, violation_content,'
. ' status, window_name, platform, crawl_date'
. ' FROM rpt.crawl_shop_violation WHERE id > ?';
$params = [$lastId];
@ -128,14 +102,6 @@ class SyncStoreViolations extends Command
continue;
}
$mapped = array_merge($mapped, $calculator->calculate(
(string) $mapped['platform'],
$mapped['shop_name'],
$mapped['violation_type'],
$mapped['violation_date'],
$mapped['appeal_deadline'],
));
$payload[] = $mapped + [
'synced_at' => $now,
'created_at' => $now,
@ -143,7 +109,7 @@ class SyncStoreViolations extends Command
];
}
$upserted += $repository->upsertRows($payload, $updateColumns);
$upserted += $repository->upsertRows($payload, StoreViolationRecordRepository::CRAWL_UPDATE_COLUMNS);
}
} catch (Throwable $exception) {
report($exception);
@ -166,47 +132,4 @@ class SyncStoreViolations extends Command
return self::SUCCESS;
}
/** @return array<string,string> 统一表字段 => 上游视图列名 */
private function availableExtraColumns(PDO $pdo): array
{
try {
$statement = $pdo->query(
"SELECT name FROM sys.columns WHERE object_id = OBJECT_ID('rpt.crawl_shop_violation')"
);
$names = $statement->fetchAll(PDO::FETCH_COLUMN);
$statement->closeCursor();
} catch (Throwable) {
return [];
}
return self::pickExtraColumns(array_map('strval', $names));
}
/**
* 从上游可用列名中挑选可同步的扩展列;列名以数据平台实际提供的为准。
*
* @param list<string> $availableColumns
* @return array<string,string>
*/
public static function pickExtraColumns(array $availableColumns): array
{
$candidates = [
'violation_reason' => ['violation_reason', 'reason'],
'disposition' => ['disposition', 'handling_method', 'disposal'],
];
$available = array_map(static fn (string $name): string => strtolower($name), $availableColumns);
$selected = [];
foreach ($candidates as $target => $names) {
foreach ($names as $name) {
if (in_array($name, $available, true)) {
$selected[$target] = $name;
break;
}
}
}
return $selected;
}
}

View File

@ -1,49 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
use Throwable;
class SyncSupplierManagementMirror extends Command
{
protected $signature = 'business-mirror:sync-supplier-management';
protected $description = '完整校准供应商管理的档案、合同、联系人和打款资料共享镜像';
public function handle(BusinessMirrorDatasetRegistry $datasets, BusinessMirrorSynchronizer $synchronizer): int
{
if (! (bool) config('upstream_mirrors.refresh_enabled', false)) {
return self::SUCCESS;
}
$failed = false;
foreach ([SupplierManagementMirrorRepository::DATASET, ...array_values(SupplierManagementMirrorRepository::RELATED)] as $key) {
if (! $datasets->scheduled($key)) {
$this->warn($key . ':未启用后台同步,已跳过。');
continue;
}
try {
// 四个已接入的小数据集顺序校准,每批沿用现有大小,直到完整结束。
$result = $synchronizer->sync($key, true, null, 0);
if ($result['status'] !== 'succeeded') {
$failed = true;
$this->error($key . ':同步未完成,请检查同步状态。');
continue;
}
$this->info(sprintf('%s:同步完成,本轮 %d 批,累计处理 %d 行。', $key, $result['batches'], $result['rows']));
} catch (Throwable $exception) {
$failed = true;
Log::error('供应商管理每日镜像同步失败。', ['dataset' => $key, 'exception' => $exception::class]);
$this->error($key . ':同步失败,请检查同步状态。');
}
}
return $failed ? self::FAILURE : self::SUCCESS;
}
}

View File

@ -1,172 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class VerifyErpDailyBillCoverage extends Command
{
protected $signature = 'business-mirror:verify-erp-daily-coverage
{--from=2026-07-01 : 起始日期 YYYY-MM-DD}
{--to= : 截止日期 YYYY-MM-DD,默认昨日}
{--json : 输出 JSON}';
protected $description = '核对京东日账单、京东钱包、头条放心购的月份与截止日覆盖是否完整';
private const TARGETS = [
'jd-daily' => [
'label' => '京东日账单',
'periods' => 'jd_daily_bill_periods',
'rows' => 'jd_daily_bill_rows',
],
'jd-wallet' => [
'label' => '京东钱包',
'periods' => 'jd_wallet_bill_periods',
'rows' => 'jd_wallet_bill_rows',
],
'douyin' => [
'label' => '头条放心购',
'periods' => 'douyin_authoritative_periods',
'rows' => 'douyin_authoritative_bills',
],
];
public function handle(): int
{
$fromText = (string) $this->option('from');
$toText = (string) ($this->option('to') ?: now('Asia/Shanghai')->subDay()->toDateString());
$from = $this->date($fromText);
$to = $this->date($toText);
if ($from === null || $to === null || $from->gt($to)) {
$this->error('日期范围无效,应为 from <= to,格式 YYYY-MM-DD。');
return self::FAILURE;
}
$db = DB::connection(config('upstream_mirrors.connection', 'business_mirror'));
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
$results = [];
$failed = false;
foreach (self::TARGETS as $key => $target) {
if (! $schema->hasTable($target['periods']) || ! $schema->hasTable($target['rows'])) {
$results[] = [
'dataset' => $key,
'label' => $target['label'],
'month' => null,
'required_start' => $from->toDateString(),
'required_through' => $to->toDateString(),
'imported_start' => null,
'imported_end' => null,
'row_count' => 0,
'registered_rows' => null,
'income' => null,
'expense' => null,
'status' => 'missing_table',
'issue' => '镜像表不存在',
];
$failed = true;
continue;
}
$month = $from->copy()->startOfMonth();
while ($month->lte($to)) {
$monthEnd = $month->copy()->endOfMonth();
$requiredStart = ($month->lt($from) ? $from->copy() : $month->copy())->startOfDay();
$requiredThrough = ($monthEnd->gt($to) ? $to->copy() : $monthEnd->copy())->startOfDay();
$requiredEndExclusive = $requiredThrough->copy()->addDay();
$period = $db->table($target['periods'])
->where('period_start', $month->toDateString())
->first();
$status = 'ok';
$rowCount = 0;
$issue = null;
$summary = null;
if ($period === null) {
$status = 'missing_month';
$issue = '缺少该月份导入记录';
} elseif (Carbon::parse((string) $period->period_end)->lt($requiredEndExclusive)) {
$status = 'partial';
$issue = '导入截止日早于要求日期';
} else {
$summary = $db->table($target['rows'])
->where('generation', $period->active_generation)
->selectRaw('COUNT(*) AS rows_count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')
->first();
$rowCount = (int) ($summary->rows_count ?? 0);
if ($rowCount === 0) {
$status = 'empty';
$issue = '有效版本没有明细';
} elseif ($rowCount !== (int) $period->row_count) {
$status = 'row_count_mismatch';
$issue = '有效版本实际行数与期间登记不一致';
} elseif (abs(round((float) $summary->income, 2) - (float) $period->income) > 0.005
|| abs(round((float) $summary->expense, 2) - (float) $period->expense) > 0.005) {
$status = 'amount_mismatch';
$issue = '有效版本收支合计与期间登记不一致';
}
}
if ($status !== 'ok') {
$failed = true;
}
$results[] = [
'dataset' => $key,
'label' => $target['label'],
'month' => $month->format('Y-m'),
'required_start' => $requiredStart->toDateString(),
'required_through' => $requiredThrough->toDateString(),
'imported_start' => $period?->period_start,
'imported_end' => $period?->period_end,
'row_count' => $rowCount,
'registered_rows' => $period === null ? null : (int) $period->row_count,
'income' => isset($summary) ? round((float) $summary->income, 2) : null,
'expense' => isset($summary) ? round((float) $summary->expense, 2) : null,
'status' => $status,
'issue' => $issue,
];
unset($summary);
$month->addMonthNoOverflow()->startOfMonth();
}
}
if ($this->option('json')) {
$this->line(json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
} else {
$this->table(
['数据集', '月份', '要求覆盖', '导入覆盖', '实际行数', '登记行数', '收入', '支出', '状态', '说明'],
array_map(static fn (array $row): array => [
$row['label'],
$row['month'] ?? '—',
($row['required_start'] ?? '—') . ' ~ ' . ($row['required_through'] ?? '—'),
($row['imported_start'] ?? '—') . ' ~ ' . ($row['imported_end'] ?? '—'),
number_format((int) $row['row_count']),
$row['registered_rows'] === null ? '—' : number_format((int) $row['registered_rows']),
$row['income'] === null ? '—' : number_format((float) $row['income'], 2),
$row['expense'] === null ? '—' : number_format((float) $row['expense'], 2),
$row['status'],
$row['issue'] ?? '',
], $results),
);
}
return $failed ? self::FAILURE : self::SUCCESS;
}
private function date(string $value): ?Carbon
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
return null;
}
$date = Carbon::createFromFormat('!Y-m-d', $value, 'Asia/Shanghai');
return $date !== false && $date->format('Y-m-d') === $value ? $date : null;
}
}

View File

@ -1,70 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class VerifyFinanceLedger extends Command
{
protected $signature = 'finance-ledger:verify';
protected $description = '校验财务台账数量、金额勾稽和流水平衡(只读)';
public function handle(): int
{
$db = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$accounts = (int) $db->table('fin_accounts')->count();
$shops = (int) $db->table('fin_shops')->count();
$companies = (int) $db->table('fin_companies')->count();
$openingRows = (int) $db->table('fin_account_opening')->count();
$transactions = (int) $db->table('fin_transactions')->count();
$flows = (int) $db->table('fin_daily_flows')->count();
$maps = (int) $db->table('fin_account_map')->count();
$mapped = (int) $db->table('fin_account_map')->whereNotNull('account_short')->where('account_short', '<>', '')->count();
$accountBalance = round((float) $db->table('fin_accounts')->sum('current_balance'), 2);
$openingSum = round((float) $db->table('fin_account_opening')->sum('opening_balance'), 2);
$income = round((float) $db->table('fin_transactions')->sum('income'), 2);
$expense = round((float) $db->table('fin_transactions')->sum('expense'), 2);
$flowOpening = round((float) $db->table('fin_daily_flows')->sum('opening_balance'), 2);
$flowInflow = round((float) $db->table('fin_daily_flows')->sum('inflow'), 2);
$flowOutflow = round((float) $db->table('fin_daily_flows')->sum('outflow'), 2);
$flowClosing = round((float) $db->table('fin_daily_flows')->sum('closing_balance'), 2);
$diff = round($flowOpening + $flowInflow - $flowOutflow - $flowClosing, 2);
$this->table(['项目', '值'], [
['账户', $accounts],
['店铺', $shops],
['公司', $companies],
['期初账户', $openingRows],
['期初合计', number_format($openingSum, 2)],
['账户余额合计', number_format($accountBalance, 2)],
['动账明细', $transactions],
['收入合计', number_format($income, 2)],
['支出合计', number_format($expense, 2)],
['每日流水', $flows],
['流水期末合计', number_format($flowClosing, 2)],
['流水勾稽差异', number_format($diff, 2)],
['映射记录', $maps],
['已映射', $mapped],
]);
$errors = [];
if (abs($openingSum - $accountBalance) > 0.01) {
$errors[] = '期初合计与账户余额合计不一致。';
}
if (abs($diff) > 0.01) {
$errors[] = '每日流水期末不等于期初+入账-出账。';
}
if ($openingRows !== $flows) {
$errors[] = '期初账户数与当前流水行数不一致(期初日应一账户一行)。';
}
foreach ($errors as $error) {
$this->error($error);
}
return $errors === [] ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -9,14 +9,14 @@ use Throwable;
class WarmOrderDataShopCandidates extends Command
{
protected $signature = 'order-data:warm-shop-candidates {--full : 全量核对店铺候选(夜间维护)}';
protected $signature = 'order-data:warm-shop-candidates';
protected $description = '增量预热订单售后费用明细的店铺候选,夜间使用 --full 全量核对';
protected $description = '预热订单售后费用明细的店铺候选(要扫两张百万级订单宽表,不能放在页面请求里)';
public function handle(OrderDataMirrorRepository $orders): int
{
try {
$names = $orders->refreshShopNamesCache((bool) $this->option('full'));
$names = $orders->refreshShopNamesCache();
$this->info(sprintf('店铺候选已预热,共 %d 个店铺。', count($names)));
return self::SUCCESS;

View File

@ -53,37 +53,12 @@ class Kernel extends ConsoleKernel
->withoutOverlapping(60)
->runInBackground();
$schedule->command('business-mirror:sync-supplier-management')
->dailyAt('08:15')
->timezone('Asia/Shanghai')
->withoutOverlapping(60)
->runInBackground();
// 店铺违规信息预警:每天同步副本库抓取结果到统一表(本地补充字段不覆盖)。
$schedule->command('store-violations:sync')
->dailyAt('05:30')
->withoutOverlapping(60)
->runInBackground();
// 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总。
$schedule->command('store-violations:push-instant')
->cron('*/5 9-21 * * *')
->withoutOverlapping(5)
->runInBackground();
$schedule->command('store-violations:push-digest')
->dailyAt('10:00')
->withoutOverlapping(30)
->runInBackground();
// 财务台账:从 AI 库 rpt 同步动账明细,按日重算账户流水。
// 生产宿主机 cron 仍需配置对应的 finance-ledger:sync-rpt 入口。
$schedule->command('finance-ledger:sync-rpt --days=7 --merge')
->dailyAt('02:30')
->timezone('Asia/Shanghai')
->withoutOverlapping(120)
->runInBackground();
$schedule->command('business-mirror:sync-third-party-warehouse-storage --max-batches=0')
->dailyAt('00:00')
->withoutOverlapping(180)
@ -96,18 +71,11 @@ class Kernel extends ConsoleKernel
->runInBackground();
}
// 聚水潭发货数据:凌晨重拉最近 40 天。聚水潭常在发货后数天到数周内换单/改快递公司/作废采购退货出库,
// 只拉最近几天会让上月数据停在旧值(快递名称不一致、已作废的单仍在);40 天覆盖上月结算期。
// 聚水潭发货数据每日重拉最近三天,覆盖副本库迟到更新。
// 默认关闭(JST_SYNC_ENABLED=false);填好凭证并完成迁移后再打开。
if ((bool) env('JST_SYNC_ENABLED', false)) {
$schedule->command('jushuitan:sync-shipping --days=40')
->dailyAt('01:28')
->withoutOverlapping(300)
->runInBackground();
// 凌晨副本库最近一两天的订单/商品明细常未到齐(视图、订单商品表滞后),
// 早上再拉一次最近 3 天,补齐凌晨只写入单号、缺商品明细的包裹。
$schedule->command('jushuitan:sync-shipping --days=3')
->dailyAt('07:28')
->dailyAt('01:28')
->withoutOverlapping(120)
->runInBackground();
}

View File

@ -100,12 +100,6 @@ class Handler extends ExceptionHandler
*/
public function render($request, Throwable $exception)
{
// 快递扫码填报页:手机原图超过上传上限时,回到填报页给出能看懂的提示,而不是报错页。
if ($exception instanceof \Illuminate\Http\Exceptions\PostTooLargeException
&& $request->is('onboarding/courier/*')) {
return redirect()->to($request->url() . '?too_large=1');
}
if (request_is_api($request)) {
return $this->handleApiExceptions($request, $exception);
}

View File

@ -157,10 +157,7 @@ class DingTalkLogin extends Controller
$url = $this->applicationRoute($user->landing_page, ['company_id' => $company->id]);
// 免登后固定回到首页(财务驾驶舱),不再回跳上次打开的业务页面。
$request->session()->forget('url.intended');
return redirect()->to($url);
return redirect()->intended($url);
}
protected function failed(): RedirectResponse

View File

@ -4,7 +4,6 @@ namespace App\Http\Controllers\Auth;
use App\Abstracts\Http\Controller;
use App\Http\Requests\Auth\Login as Request;
use App\Models\Common\Contact;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@ -80,14 +79,10 @@ class Login extends Controller
// Redirect to portal if is customer
if ($user->isCustomer()) {
$path = session('url.intended', '');
$portalHome = $user->contact instanceof Contact
&& $user->contact->type === Contact::VENDOR_TYPE
? route('portal.supplier-reconciliation.index', ['company_id' => $company->id])
: route('portal.dashboard', ['company_id' => $company->id]);
// Path must start with company id and 'portal' prefix
if (!Str::startsWith($path, $company->id . '/portal')) {
$path = $portalHome;
$path = route('portal.dashboard', ['company_id' => $company->id]);
}
if (! $request->expectsJson()) {
@ -107,11 +102,8 @@ class Login extends Controller
// Redirect to landing page if is user
$url = route($user->landing_page, ['company_id' => $company->id]);
// 登录后固定回到首页(财务驾驶舱),不再回跳上次打开/被拦截的业务页面。
$request->session()->forget('url.intended');
if (! $request->expectsJson()) {
return redirect()->to($url);
return redirect()->intended($url);
}
return response()->json([
@ -120,7 +112,7 @@ class Login extends Controller
'error' => false,
'message' => trans('auth.login_redirect'),
'data' => null,
'redirect' => url($url),
'redirect' => redirect()->intended($url)->getTargetUrl(),
]);
}

View File

@ -1,93 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierImporter;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierStore;
use Modules\EcommerceFinance\Services\AccessoryReshipRateCalculator;
use Modules\EcommerceFinance\View\AccessoryReshipConfigPageData;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class AccessoryReshipCourierConfigs extends Controller
{
public function __construct(private readonly AccessoryReshipCourierStore $store) {}
public function store(Request $request)
{
$this->store->save((int) company_id(), $this->validated($request), $this->actor());
return $this->back('快递扣款配置已新增。');
}
public function update(Request $request, int $configId)
{
$this->store->save((int) company_id(), $this->validated($request), $this->actor(), $configId);
return $this->back('快递扣款配置已更新,核对清单将按新比例重新计算。');
}
public function destroy(int $configId)
{
$this->store->changeDeleted((int) company_id(), $configId, true, $this->actor());
return $this->back('配置已删除,该快递暂不参与扣款计算。');
}
public function restore(int $configId)
{
$this->store->changeDeleted((int) company_id(), $configId, false, $this->actor());
return $this->back('快递扣款配置已恢复。');
}
public function import(Request $request, AccessoryReshipCourierImporter $importer)
{
$request->validate(['file' => ['required', 'file', 'mimes:xlsx', 'max:10240']]);
$path = $request->file('file')->getRealPath();
$rows = $importer->read($path);
$result = $this->store->import((int) company_id(), $rows, $this->actor(), hash_file('sha256', $path));
return $this->back("导入完成:新增{$result['added']}条,重复跳过{$result['skipped']}条。");
}
public function export(AccessoryReshipConfigPageData $data)
{
$rows = $this->store->filtered((int) company_id(), $data->filters())->get();
$book = new Spreadsheet();
$sheet = $book->getActiveSheet();
$sheet->setTitle('快递配置');
$sheet->fromArray([['快递代码', '快递公司', '创建时间', '创建人', '扣款比率(%)', '删除状态']]);
foreach ($rows as $index => $row) {
$number = $index + 2;
foreach ([$row->courier_code, $row->courier_name, $row->source_created_at ?: $row->created_at, $row->creator] as $column => $value) {
$sheet->setCellValueExplicit([$column + 1, $number], (string) $value, DataType::TYPE_STRING);
}
$sheet->setCellValue([5, $number], $row->rate_basis_points / 10000);
$sheet->getStyle('E' . $number)->getNumberFormat()->setFormatCode('0.00%');
$sheet->setCellValueExplicit([6, $number], $row->deleted_at ? '已删除' : '正常', DataType::TYPE_STRING);
}
foreach (['A' => 24, 'B' => 30, 'C' => 24, 'D' => 18, 'E' => 18, 'F' => 14] as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A2');
return response()->streamDownload(function () use ($book): void {
try { (new Xlsx($book))->save('php://output'); } finally { $book->disconnectWorksheets(); }
}, '快递扣款配置.xlsx', ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']);
}
private function validated(Request $request): array
{
$data = $request->validate(['courier_code' => ['required', 'string', 'max:64'], 'courier_name' => ['required', 'string', 'max:191'], 'rate_percent' => ['required', 'string']]);
$data['rate_basis_points'] = AccessoryReshipRateCalculator::basisPoints($data['rate_percent']);
return $data;
}
private function actor(): string
{
return mb_substr((string) user()->name, 0, 191);
}
private function back(string $message)
{
return redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'accessory-reship-deductions', 'reship_view' => 'config'])->with('reship_config_message', $message);
}
}

View File

@ -1,48 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\View\AccessoryReshipPageData;
class AccessoryReshipDeductions extends Controller
{
public function selectCourier(\Illuminate\Http\Request $request, AccessoryReshipPageData $data, \Modules\EcommerceFinance\Services\AccessoryReshipSelectionStore $store)
{
$input = $request->validate([
'month' => ['required', 'date_format:Y-m', 'after_or_equal:2000-01'],
'row_key' => ['required', 'regex:/^[a-f0-9]{64}$/'],
'courier' => ['required', 'string', 'max:191'],
]);
// 重新读取候选,禁止提交任意快递名称或其他公司的包裹。
$found = false;
foreach ($data->rows(['month' => $input['month']]) as $row) {
if ($row['selection_key'] !== $input['row_key']) continue;
$found = in_array($input['courier'], array_column($row['courier_candidates'], 'courier'), true);
break;
}
abort_unless($found, 422, '该快递已不在当前首发候选中,请刷新后重新选择。');
$store->save((int) company_id(), $input['month'], $input['row_key'], $input['courier'], mb_substr((string) user()->name, 0, 191));
return response()->json(['message' => '扣款快递已保存。']);
}
public function export(AccessoryReshipPageData $data)
{
$filters = $data->filters();
// 先完成读取,失败时返回正常错误,避免下载半份扣款清单。
$file = tmpfile();
if ($file === false) abort(500, '无法创建导出文件。');
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, ['交易单号','补发快递单号','补发快递','补发时间','原始条形码','处理后条形码','补发运费','匹配状态','首发快递公司(候选)','首发快递单号(候选)','首发所属月份(候选)','扣款比例(%)','扣款金额','说明']);
foreach ($data->rows($filters) as $row) {
if (!AccessoryReshipPageData::inCategory($row, $filters['match_status'])) continue;
$values = [$row['trade_no'], $row['express_no'], ($row['express_name1'] ?: ($row['express_name2'] ?? '')), $row['ship_time'], $row['barcode'], $row['normalized_barcode'], $row['freight'], $row['deduction_state'] === 'ready' ? '可扣款' : ($row['status'] === 'matched' ? '已匹配待核对' : '未匹配不扣款'), implode(' / ', array_column($row['matches'], 'courier')), implode(' / ', array_column($row['matches'], 'express_no')), implode(' / ', array_column($row['matches'], 'month')), $row['deduction_rate_basis_points'] === null ? '' : number_format($row['deduction_rate_basis_points'] / 100, 2, '.', '') . '%', $row['deduction_cents'] === null ? '' : number_format($row['deduction_cents'] / 100, 2, '.', ''), $row['deduction_note']];
fputcsv($file, array_map(static function ($value) {
$value = (string) $value;
return preg_match('/^[=+\-@\t\r\n]/u', $value) ? "'" . $value : $value;
}, $values));
}
rewind($file);
return response()->streamDownload(function () use ($file) { fpassthru($file); fclose($file); }, '配件补发扣款-' . $filters['month'] . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@ -12,7 +12,6 @@ use Modules\EcommerceFinance\Services\AccountMappingCoa;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\AccountMappingValidator;
use Modules\EcommerceFinance\Services\AccountMappingWorkbookImporter;
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -43,7 +42,6 @@ class AccountMappingActions extends Controller
'memo' => '财务备注',
'invoice' => '票据口径',
'category' => '大类',
'feeMemo' => '费用备注',
'dAcct' => '借方科目',
'dSub' => '借方二级',
'cAcct' => '贷方科目',
@ -76,37 +74,27 @@ class AccountMappingActions extends Controller
$this->store->create($values, $this->userId());
$hint = $this->dispatchResync([(string) ($values['platform'] ?? '')]);
return response()->json(['message' => '已新增 1 条规则。' . $hint] + $this->payload());
return response()->json(['message' => '已新增 1 条规则。'] + $this->payload());
}
public function update(Request $request, int $id): JsonResponse
{
$before = $this->store->find($id);
if ($before === null) {
if ($this->store->find($id) === null) {
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
}
$values = $this->payloadValues($request);
$after = $this->store->update($id, $values, $this->userId());
$this->store->update($id, $this->payloadValues($request), $this->userId());
$hint = AccountMappingStore::requiresReceiptResync($before, $after)
? $this->dispatchResync([(string) ($before['platform'] ?? ''), (string) ($after['platform'] ?? '')]) : '';
return response()->json(['message' => '已保存。' . $hint] + $this->payload());
return response()->json(['message' => '已保存。'] + $this->payload());
}
public function destroy(int $id): JsonResponse
{
$before = $this->store->find($id);
if (! $this->store->destroy($id, $this->userId())) {
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
}
$hint = $this->dispatchResync([(string) ($before['platform'] ?? '')]);
return response()->json(['message' => '已删除 1 条规则。' . $hint] + $this->payload());
return response()->json(['message' => '已删除 1 条规则。'] + $this->payload());
}
/**
@ -193,24 +181,21 @@ class AccountMappingActions extends Controller
}
$restored = $this->store->create($before, $this->userId());
$hint = $this->dispatchResync([(string) ($restored['platform'] ?? '')]);
return response()->json([
'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。' . $hint,
'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。',
] + $this->payload());
}
if ($action === 'update') {
$current = $this->store->find($ruleId);
if ($current === null) {
if ($this->store->find($ruleId) === null) {
throw ValidationException::withMessages(['history' => '这条规则已经被删掉了,请用同一条的「删除」记录恢复。']);
}
$this->store->update($ruleId, $before, $this->userId());
$hint = $this->dispatchResync([(string) ($current['platform'] ?? ''), (string) ($before['platform'] ?? '')]);
return response()->json([
'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。' . $hint,
'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。',
] + $this->payload());
}
@ -242,8 +227,6 @@ class AccountMappingActions extends Controller
$parsed = $this->importer->parse($file);
$result = $this->store->importRows($parsed['rows'], $request->boolean('overwrite'), $this->userId());
$message = "导入完成:识别 {$result['total']} 行,新增 {$result['created']} 条,更新 {$result['updated']} 条,跳过重复 {$result['skipped']} 条。";
$platforms = $result['resync_platforms'];
$message .= $this->dispatchResync($platforms);
return response()->json(['message' => $message, 'result' => $result] + $this->payload());
}
@ -319,7 +302,7 @@ class AccountMappingActions extends Controller
{
$total = $this->store->reset($this->userId());
return response()->json(['message' => "已恢复为初始 {$total} 条规则。" . $this->dispatchResyncAll()] + $this->payload());
return response()->json(['message' => "已恢复为初始 {$total} 条规则。"] + $this->payload());
}
/** 账套科目(一级 + 各一级下的明细),供下拉使用 */
@ -370,8 +353,8 @@ class AccountMappingActions extends Controller
public function template(): StreamedResponse
{
$samples = [
['得物', '平台服务费', '平台服务费', '-', '0.06', '费用', '平台服务费', '销售费用', '平台服务费', '其他货币资金', '', '支出基准'],
['淘宝', '交易收入', '订单', '-', '-', '收入', '收入', '其他货币资金', '', '主营业务收入', '', '收入基准'],
['得物', '平台服务费', '平台服务费', '0.06', '费用', '销售费用', '平台佣金', '其他货币资金', '', '支出基准'],
['淘宝', '交易收入', '订单', '-', '收入', '其他货币资金', '', '主营业务收入', '', '收入基准'],
];
return response()->streamDownload(function () use ($samples): void {
@ -423,44 +406,6 @@ class AccountMappingActions extends Controller
return user_id() ? (int) user_id() : null;
}
/**
* 保存映射后把受影响的平台排进后台重跑队列。
*
* 映射表本身是实时读取的,但业绩明细的优惠/冲减/退款金额是同步时落库的,
* 所以保存成功后需要自动重跑对应平台和月份。队列创建失败不能把已经保存成功的规则回滚。
*
* @param list<string> $platforms
*/
private function dispatchResync(array $platforms): string
{
try {
$result = app(AccountMappingResyncDispatcher::class)->dispatchFor($platforms);
} catch (\Throwable $exception) {
report($exception);
return ' 业绩明细自动重跑任务创建失败,请稍后重试保存或联系管理员。';
}
return $result['jobs'] > 0
? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。"
: '';
}
private function dispatchResyncAll(): string
{
try {
$result = app(AccountMappingResyncDispatcher::class)->dispatchAll();
} catch (\Throwable $exception) {
report($exception);
return ' 业绩明细自动重跑任务创建失败,请稍后重试或联系管理员。';
}
return $result['jobs'] > 0
? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。"
: '';
}
/**
* 原始操作记录 + 页面/CSV 直接可用的字段(变更内容与操作人只在服务端算一次)。
*

View File

@ -8,12 +8,10 @@ use App\Models\Business\CourierQuoteVersion;
use App\Models\Business\CourierSigningSubject;
use App\Services\Esign\EsignClient;
use App\Support\CourierAgreementFileArchive;
use App\Support\CourierAgreementCredentials;
use App\Support\CourierAgreementSignatureService;
use App\Support\CourierQuoteConfirmation;
use App\Support\CourierQuoteSignatureService;
use App\Support\SupplierCooperationTemplates;
use App\Utilities\AgreementSigningPermissions;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -21,7 +19,6 @@ use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Rule;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -40,20 +37,6 @@ class CourierAgreementSignatures extends Controller
public function store(Request $request): RedirectResponse
{
$this->ensureCanManage();
$request->merge(['partner_type' => $request->input('partner_type', 'courier')]);
$request->validate(['partner_type' => ['required', 'in:courier,supplier']]);
if ($request->input('partner_type') === 'supplier') {
if ($request->input('document_type') === 'cooperation-contract') {
$request->validate(['supplier_contract_template' => ['required', Rule::in(array_keys(SupplierCooperationTemplates::TEMPLATES))]]);
}
$request->validate(['supplier_id' => ['required', 'integer', 'min:1'],
'document_type' => ['required', 'in:account-proof,account-change,entity-change,authorization,cooperation-contract']]);
$supplier = app(\Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository::class)
->signingSupplier((int) $request->input('supplier_id'));
if (! $supplier) throw ValidationException::withMessages(['supplier_id' => '供应商资料暂不可用,请重新选择供应商。']);
$request->merge(['courier_name' => trim((string) $supplier->scmname)]);
if (! $request->filled('esign_account')) $request->merge(['esign_account' => trim((string) $supplier->accountbankno)]);
}
abort_unless(EsignClient::configured(), 422, 'E 签宝沙箱尚未配置 App ID 或 AppSecret。');
$requestedType = (string) $request->input('document_type');
if ($requestedType === 'quote-confirmation') {
@ -66,34 +49,26 @@ class CourierAgreementSignatures extends Controller
abort_unless(Schema::hasTable('business_courier_agreement_signatures'), 503, '协议签署数据表尚未完成迁移,请先执行数据库迁移。');
}
if ($request->input('document_type') === 'cooperation-contract' && $request->input('partner_type') === 'courier') {
if ($request->input('document_type') === 'cooperation-contract') {
// 合作合同统一由我方固定 E 签宝账号完成第二签署方签署。
$request->merge([
'secondary_esign_account' => self::COOPERATION_SECONDARY_ESIGN_ACCOUNT,
]);
}
if (in_array($requestedType, ['account-proof', 'account-change', 'entity-change', 'cooperation-contract', 'authorization', 'shared-contract'], true)
if (in_array($requestedType, ['account-proof', 'account-change'], true)
&& ! $request->filled('counterparty_signer_type')) {
// Existing integrations omit this field and historically meant an
// enterprise signer, so keep that behavior as the compatibility default.
$request->merge(['counterparty_signer_type' => 'organization']);
}
$data = $request->validate($this->rules(), [
'secondary_operator_name.required_if' => '请填写我方 E 签宝账号绑定的实名认证姓名。',
]);
$data = $request->validate($this->rules());
$type = $data['document_type'];
if ($type === 'authorization' && count($data['authorization_items'] ?? []) === 0) {
throw ValidationException::withMessages(['authorization_items' => '授权委托书至少选择一项授权事项。']);
}
if ($type === 'shared-contract') {
$data['contract_partner_name'] = trim((string) $data['contract_partner_name']);
if ($data['counterparty_signer_type'] === 'person') {
$data['operator_name'] = $data['contract_partner_name'];
} elseif (blank($data['operator_name'] ?? null)) {
throw ValidationException::withMessages(['operator_name' => '请填写物流商归属企业经办人的 E 签宝实名认证姓名。']);
}
$sharedCouriers = collect($data['shared_courier_names'] ?? [])
->map(static fn ($name): string => trim((string) $name))
->filter()
@ -125,64 +100,6 @@ class CourierAgreementSignatures extends Controller
$data['operator_name'] = trim((string) $data['counterparty_organization_name']);
}
}
if ($type === 'entity-change') {
$formerCompanyDeregistered = filter_var(
$data['entity_change_old_company_deregistered'] ?? false,
FILTER_VALIDATE_BOOLEAN,
);
$data['entity_change_old_company_deregistered'] = $formerCompanyDeregistered;
$data['counterparty_signer_type'] = $formerCompanyDeregistered ? 'person' : 'organization';
if (blank($data['operator_name'] ?? null)) {
throw ValidationException::withMessages(['operator_name' => $formerCompanyDeregistered
? '请填写原公司法人的 E 签宝实名认证姓名。'
: '请填写变更前主体签署人的 E 签宝实名认证姓名。']);
}
if (blank($data['secondary_esign_account'] ?? null)) {
throw ValidationException::withMessages(['secondary_esign_account' => '请填写变更后主体的 E 签宝账号。']);
}
if (blank($data['secondary_operator_name'] ?? null)) {
throw ValidationException::withMessages(['secondary_operator_name' => '请填写变更后主体签署人的 E 签宝实名认证姓名。']);
}
}
if ($type === 'cooperation-contract') {
$data['counterparty_signer_type'] = $data['counterparty_signer_type'] ?? 'organization';
if ($data['counterparty_signer_type'] === 'person') {
$data['operator_name'] = trim((string) $data['cooperation_party_b_name']);
} elseif (blank($data['operator_name'] ?? null)) {
$party = $data['partner_type'] === 'supplier' ? '供应商' : '快递方';
throw ValidationException::withMessages(['operator_name' => "请填写{$party}企业经办人的 E 签宝实名认证姓名。"]);
}
if (blank($data['secondary_operator_name'] ?? null)) {
throw ValidationException::withMessages(['secondary_operator_name' => '请填写我方 E 签宝账号绑定的实名认证姓名。']);
}
$companyNames = [trim($data['cooperation_party_a_name'])];
if ($data['counterparty_signer_type'] === 'organization') {
$companyNames[] = trim($data['cooperation_party_b_name']);
}
foreach (['operator_name' => $data['partner_type'] === 'supplier' ? '供应商' : '快递方', 'secondary_operator_name' => '我方'] as $field => $party) {
$data[$field] = trim($data[$field]);
if (in_array($data[$field], $companyNames, true)) {
throw ValidationException::withMessages([
$field => "{$party}实名认证姓名应填写账号绑定的个人姓名,不能填写甲乙方公司名称。",
]);
}
}
}
if ($type === 'authorization') {
$data['counterparty_signer_type'] = $data['counterparty_signer_type'] ?? 'organization';
$data['secondary_signer_type'] = $data['secondary_signer_type'] ?? 'organization';
if ($data['counterparty_signer_type'] === 'person') {
$data['operator_name'] = trim((string) $data['principal_name']);
} elseif (blank($data['operator_name'] ?? null)) {
throw ValidationException::withMessages(['operator_name' => '请填写委托方企业经办人的 E 签宝实名认证姓名。']);
}
if ($data['secondary_signer_type'] === 'person') {
$data['secondary_operator_name'] = trim((string) $data['trustee_name']);
} elseif (blank($data['secondary_operator_name'] ?? null)) {
throw ValidationException::withMessages(['secondary_operator_name' => '请填写受托方企业经办人的 E 签宝实名认证姓名。']);
}
}
$this->validateEsignAccount($data['esign_account'], 'esign_account');
if (! empty($data['secondary_esign_account'])) {
$this->validateEsignAccount($data['secondary_esign_account'], 'secondary_esign_account');
@ -199,12 +116,6 @@ class CourierAgreementSignatures extends Controller
$data['operator_account'] = $data['esign_account'];
$data['payload'] = $this->payload($data);
$data['payload']['credential_required'] = $request->boolean('credential_required');
$data['payload']['credential_signer'] = (int) ($data['credential_signer'] ?? ($type === 'entity-change' ? 1 : 0));
if ($request->boolean('credential_required') && $type === 'entity-change'
&& $data['payload']['credential_signer'] === 0 && $data['counterparty_signer_type'] === 'person') {
throw ValidationException::withMessages(['credential_signer' => '营业执照需要企业盖章,请选择变更后主体签署证件。']);
}
$signature = $this->service->send($data, user_id());
@ -224,7 +135,7 @@ class CourierAgreementSignatures extends Controller
*/
public function storeSubject(Request $request): JsonResponse
{
$this->ensureCanAdminister();
$this->ensureCanManage();
if ($response = $this->subjectsTableUnavailable()) {
return $response;
@ -251,7 +162,7 @@ class CourierAgreementSignatures extends Controller
public function updateSubject(Request $request, CourierSigningSubject $subject): JsonResponse
{
$this->ensureCanAdminister();
$this->ensureCanManage();
$this->ensureSubjectBelongsToCurrentCompany($subject);
if ($response = $this->subjectsTableUnavailable()) {
@ -272,7 +183,7 @@ class CourierAgreementSignatures extends Controller
public function setDefaultSubject(CourierSigningSubject $subject): JsonResponse
{
$this->ensureCanAdminister();
$this->ensureCanManage();
$this->ensureSubjectBelongsToCurrentCompany($subject);
if ($response = $this->subjectsTableUnavailable()) {
@ -297,32 +208,6 @@ class CourierAgreementSignatures extends Controller
return $this->subjectResponse($subject->fresh(), '默认签署主体已切换。');
}
public function destroySubject(Request $request, CourierSigningSubject $subject): JsonResponse
{
$this->ensureCanAdminister();
$this->ensureSubjectBelongsToCurrentCompany($subject);
if ($response = $this->subjectsTableUnavailable()) {
return $response;
}
$request->validate(['delete_confirmed' => ['required', 'accepted']]);
DB::transaction(function () use ($subject): void {
$subject = CourierSigningSubject::query()->disableCache()->lockForUpdate()->findOrFail($subject->id);
$wasDefault = $subject->is_default;
$subject->forceFill(['is_default' => false, 'updated_by' => user_id()])->save();
$subject->delete();
if ($wasDefault) {
$nextDefault = CourierSigningSubject::query()->disableCache()->orderBy('id')->lockForUpdate()->first();
$nextDefault?->forceFill(['is_default' => true, 'updated_by' => user_id()])->save();
}
});
return $this->subjectResponse(null, '公司主体已删除,历史协议的主体快照保留。');
}
public function synchronize(CourierAgreementSignature $agreementSignature): RedirectResponse
{
$this->ensureCanManage();
@ -333,77 +218,20 @@ class CourierAgreementSignatures extends Controller
->with('success', "{$signature->documentLabel()}状态已同步:{$signature->statusLabel()}。");
}
public function archive(Request $request, CourierAgreementSignature $agreementSignature): RedirectResponse
public function archive(CourierAgreementSignature $agreementSignature): RedirectResponse
{
$this->ensureCanManage();
abort_unless($agreementSignature->status === CourierAgreementSignature::STATUS_COMPLETED && $agreementSignature->remote_status === '2', 422, '只有签署完成的协议可以人工归档。');
$data = $request->validate([
'archive_reason_code' => ['required', 'in:completed,confirmed,backfill,other'],
'archive_note' => ['nullable', 'string', 'max:200'],
]);
$reasonLabel = match ($data['archive_reason_code']) {
'completed' => '签署完成',
'confirmed' => '财务已确认',
'backfill' => '历史补录',
'other' => '其他',
};
$note = trim((string) ($data['archive_note'] ?? ''));
if ($data['archive_reason_code'] === 'other' && $note === '') {
throw ValidationException::withMessages(['archive_note' => '选择“其他”时请填写归档说明。']);
}
$reason = $note === '' ? $reasonLabel : $reasonLabel . ':' . $note;
$signature = $this->service->archive($agreementSignature, null, user_id(), $reason);
$signature = $this->service->archive($agreementSignature, null, user_id());
return redirect()
->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
->with('success', "{$signature->documentLabel()}已人工确认并归档。");
}
public function destroy(Request $request, CourierAgreementSignature $agreementSignature): JsonResponse|RedirectResponse
{
return $this->deleteTask($request, $agreementSignature);
}
public function destroyQuote(Request $request, CourierQuoteSignature $quoteSignature): JsonResponse|RedirectResponse
{
return $this->deleteTask($request, $quoteSignature);
}
private function deleteTask(Request $request, CourierAgreementSignature|CourierQuoteSignature $task): JsonResponse|RedirectResponse
{
$this->ensureCanAdminister();
abort_unless($task->company_id === (int) company_id(), 404);
$request->validate(['delete_confirmed' => ['required', 'accepted']]);
DB::transaction(function () use ($task): void {
$signature = $task::query()->disableCache()->lockForUpdate()->findOrFail($task->id);
// Soft-delete only the local task, retaining its files, remote flow and audit records.
$signature->updated_by = user_id();
$signature->save();
$signature->delete();
});
$message = '签署任务已删除。';
return $request->expectsJson()
? response()->json(['message' => $message])
: redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
->with('success', $message);
}
public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse|RedirectResponse
{
$this->ensureCanManage();
abort_unless($agreementSignature->company_id === (int) company_id(), 404);
if (preg_match('/^credential-(license|identity|bank)-(source|signed)$/', $kind, $match)) {
$document = $agreementSignature->credentialDocuments()[$match[1]] ?? null;
abort_unless($document, 404);
$path = $document[$match[2] . '_path'] ?? null;
abort_if(blank($path) || ! \Storage::disk('uploads')->exists($path), 404, '证件推送单不存在。');
return \Storage::disk('uploads')->response($path,
$agreementSignature->business_no . '-' . $document['name'] . ($match[2] === 'signed' ? '-已签署' : '-原文') . '.pdf',
['Content-Type' => 'application/pdf'], request()->boolean('preview') ? 'inline' : 'attachment');
}
try {
[$path, $suffix] = match ($kind) {
'source' => [$this->agreementFileArchive->sourcePath($agreementSignature), '签署原文'],
@ -464,23 +292,7 @@ class CourierAgreementSignatures extends Controller
{
$date = ['required', 'date', 'after_or_equal:today', 'before_or_equal:' . now()->addDays(90)->toDateString()];
$supplierContract = request('partner_type') === 'supplier' && request('document_type') === 'cooperation-contract';
$supplierFields = [];
foreach (SupplierCooperationTemplates::FIELDS as $key => $field) {
$supplierFields['supplier_contract_fields.' . $key] = [
Rule::requiredIf($supplierContract && ($field['required'] ?? false)), 'nullable', 'string',
'max:' . ($key === 'supply_price' ? 1000 : 255),
];
if (($field['type'] ?? '') === 'email') {
$supplierFields['supplier_contract_fields.' . $key][] = 'email';
}
}
return [
'supplier_contract_template' => [Rule::requiredIf($supplierContract), 'nullable', Rule::in(array_keys(SupplierCooperationTemplates::TEMPLATES)), Rule::prohibitedIf(! $supplierContract)],
'supplier_contract_fields' => ['nullable', 'array:' . implode(',', array_keys(SupplierCooperationTemplates::FIELDS))],
'partner_type' => ['required', 'in:courier,supplier'],
'supplier_id' => ['required_if:partner_type,supplier', 'nullable', 'integer', 'min:1'],
'idempotency_key' => ['required', 'string', 'max:80', 'regex:/^[A-Za-z0-9_-]+$/'],
'courier_name' => ['required', 'string', 'max:255'],
'document_type' => ['required', 'in:' . implode(',', array_keys(CourierAgreementSignature::DOCUMENTS))],
@ -490,13 +302,10 @@ class CourierAgreementSignatures extends Controller
'signature_date' => ['required', 'date'],
'signing_deadline' => $date,
'esign_account' => ['required', 'string', 'max:255'],
'secondary_esign_account' => ['required_if:document_type,cooperation-contract,authorization', 'nullable', 'string', 'max:255'],
'secondary_operator_name' => ['nullable', 'string', 'min:2', 'max:100'],
'secondary_esign_account' => ['required_if:document_type,entity-change,cooperation-contract,authorization', 'nullable', 'string', 'max:255'],
'operator_name' => ['nullable', 'string', 'min:2', 'max:100'],
'counterparty_organization_name' => ['required_if:document_type,account-proof,account-change', 'nullable', 'string', 'min:2', 'max:255'],
'counterparty_signer_type' => ['required_if:document_type,account-proof,account-change,entity-change,cooperation-contract,authorization,shared-contract', 'nullable', 'in:organization,person'],
'secondary_signer_type' => ['nullable', 'in:organization,person'],
'entity_change_old_company_deregistered' => ['nullable', 'boolean'],
'counterparty_signer_type' => ['required_if:document_type,account-proof,account-change', 'nullable', 'in:organization,person'],
'archive_mode' => ['required', 'in:auto,manual'],
'account_proof_effective_date' => ['required_if:document_type,account-proof', 'date'],
'account_proof_account_name' => ['required_if:document_type,account-proof', 'string', 'max:255'],
@ -514,7 +323,7 @@ class CourierAgreementSignatures extends Controller
'entity_change_date' => ['required_if:document_type,entity-change', 'date'],
'changed_company_name' => ['required_if:document_type,entity-change', 'string', 'max:255'],
'changed_company_start_date' => ['required_if:document_type,entity-change', 'date'],
'contract_partner_name' => ['required_if:document_type,shared-contract', 'string', 'min:2', 'max:255'],
'contract_partner_name' => ['required_if:document_type,shared-contract', 'string', 'max:255'],
'shared_courier_names' => ['nullable', 'array', 'min:1'],
'shared_courier_names.*' => ['required', 'string', 'max:255'],
'shared_courier_name' => ['nullable', 'string', 'max:255'],
@ -523,9 +332,6 @@ class CourierAgreementSignatures extends Controller
'cooperation_party_a_phone' => ['required_if:document_type,cooperation-contract', 'string', 'max:64'],
'cooperation_party_b_name' => ['required_if:document_type,cooperation-contract', 'string', 'max:255'],
'cooperation_party_b_phone' => ['required_if:document_type,cooperation-contract', 'string', 'max:64'],
'cooperation_party_b_signing_place' => ['nullable', 'string', 'max:100'],
'cooperation_contract_number' => ['nullable', 'string', 'max:100'],
'cooperation_party_b_legal_representative' => ['nullable', 'string', 'max:100'],
'cooperation_start_date' => ['required_if:document_type,cooperation-contract', 'date'],
'cooperation_end_date' => ['required_if:document_type,cooperation-contract', 'date', 'after_or_equal:cooperation_start_date'],
'principal_name' => ['required_if:document_type,authorization', 'string', 'max:100'],
@ -539,28 +345,15 @@ class CourierAgreementSignatures extends Controller
'payment_counterparty' => ['required_if:document_type,authorization', 'string', 'max:255'],
'authorization_start_date' => ['required_if:document_type,authorization', 'date'],
'authorization_end_date' => ['required_if:document_type,authorization', 'date', 'after_or_equal:authorization_start_date'],
] + $supplierFields + CourierAgreementCredentials::rules((string) request('document_type'), request()->boolean('credential_required'));
];
}
private function payload(array $data): array
{
$allowed = array_keys($this->rules());
$payload = array_intersect_key($data, array_flip($allowed));
unset($payload['esign_account'], $payload['idempotency_key'], $payload['operator_name'], $payload['credential_images']);
unset($payload['esign_account'], $payload['idempotency_key'], $payload['operator_name']);
$payload['authorization_items'] = array_values(array_map('strval', $data['authorization_items'] ?? []));
if (($data['document_type'] ?? '') === 'cooperation-contract' && ($data['partner_type'] ?? 'courier') === 'courier') {
$payload['cooperation_party_b_signing_place'] = trim((string) ($data['cooperation_party_b_signing_place'] ?? '')) ?: '常州市';
$payload['cooperation_contract_number'] = trim((string) ($data['cooperation_contract_number'] ?? ''));
$payload['cooperation_party_b_legal_representative'] = ($data['counterparty_signer_type'] ?? 'organization') === 'person'
? null : trim((string) ($data['cooperation_party_b_legal_representative'] ?? ''));
}
if (($data['document_type'] ?? null) === 'entity-change') {
$payload['former_legal_representative'] = trim((string) ($data['operator_name'] ?? ''));
$payload['entity_proof_date'] = $data['signature_date'] ?? null;
}
if (($data['partner_type'] ?? 'courier') === 'supplier' && ($data['document_type'] ?? '') === 'cooperation-contract') {
$payload['supplier_contract_signer_name'] = $data['operator_name'];
}
return $payload;
}
@ -600,7 +393,7 @@ class CourierAgreementSignatures extends Controller
{
return match ($data['document_type']) {
'account-proof', 'account-change' => trim((string) $data['counterparty_organization_name']),
'shared-contract' => trim((string) $data['contract_partner_name']),
'shared-contract' => trim((string) (($data['shared_courier_names'][0] ?? null) ?: ($data['shared_courier_name'] ?? $data['courier_name']))),
'entity-change' => trim((string) ($data['former_company_name'] ?? $data['courier_name'])),
'authorization' => trim((string) ($data['principal_name'] ?? $data['courier_name'])),
default => trim((string) $data['courier_name']),
@ -610,17 +403,12 @@ class CourierAgreementSignatures extends Controller
private function ensureCanManage(): void
{
abort_unless(
AgreementSigningPermissions::canOperate(),
in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true),
403,
'当前账号没有协议签署操作权限,请联系管理员授权。',
'当前账号没有协议签署管理权限。',
);
}
private function ensureCanAdminister(): void
{
abort_unless(AgreementSigningPermissions::canAdminister(), 403, '当前账号没有协议签署管理权限。');
}
private function subjectData(Request $request): array
{
$data = $request->validate([
@ -651,11 +439,11 @@ class CourierAgreementSignatures extends Controller
}
}
private function subjectResponse(?CourierSigningSubject $subject, string $message): JsonResponse
private function subjectResponse(CourierSigningSubject $subject, string $message): JsonResponse
{
return response()->json([
'message' => $message,
'subject' => $subject ? $this->serializeSubject($subject) : null,
'subject' => $this->serializeSubject($subject),
'subjects' => CourierSigningSubject::query()
->orderByDesc('is_default')
->orderBy('name')

View File

@ -1,28 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierAgreementSignature;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierContracts extends Controller
{
/** A read-only archive entry point with courier-information page access, not signing-management access. */
public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse
{
abort_unless($agreementSignature->company_id === (int) company_id()
&& $agreementSignature->archivedCourierContractPayload() !== null, 404);
$path = match ($kind) {
'signed' => $agreementSignature->signed_pdf_path,
'certificate' => $agreementSignature->certificate_path,
default => abort(404),
};
abort_if(blank($path) || ! Storage::disk('uploads')->exists($path), 404, '归档附件不存在,请核对协议签署记录。');
$suffix = $kind === 'signed' ? '已签署合作合同' : '签署完成证书';
return Storage::disk('uploads')->response($path, $agreementSignature->business_no . '-' . $suffix . '.pdf',
['Content-Type' => 'application/pdf'], request()->boolean('preview') ? 'inline' : 'attachment');
}
}

View File

@ -1,367 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingEvent;
use App\Models\Business\CourierOnboardingFile;
use App\Support\CourierOnboardingService;
use App\Support\CourierOnboardingReviewers;
use App\Support\QrCode;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierOnboardings extends Controller
{
public function __construct(private readonly CourierOnboardingService $service)
{
}
public function store(Request $request): RedirectResponse
{
$this->ensureReady();
$data = $request->validate([
'courier_name' => ['required', 'string', 'max:100'],
'invite_label' => ['nullable', 'string', 'max:100'],
'valid_days' => ['nullable', 'integer', 'min:1', 'max:180'],
'workplatform_expcode' => ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z_\-]+$/'],
...CourierOnboarding::profileRules(),
], [
'courier_name.required' => '请先定好系统快递名称(需与报价名、发货数据里的快递名称一致)。',
'workplatform_expcode.required' => '请填写快递公司代码(与快递资料「快递公司代码」一致)。',
'workplatform_expcode.regex' => '快递公司代码只能包含字母、数字、下划线和短横线。',
...CourierOnboarding::profileMessages(),
], CourierOnboarding::profileAttributes());
$code = strtoupper(trim((string) $data['workplatform_expcode']));
$taken = CourierOnboarding::query()
->where('workplatform_expcode', $code)
->whereNotIn('status', [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED])
->exists();
if ($taken) {
throw ValidationException::withMessages(['workplatform_expcode' => "快递公司代码「{$code}」已被其他入驻申请使用。"]);
}
$onboarding = $this->service->invite(
$data['invite_label'] ?? null,
(int) ($data['valid_days'] ?? CourierOnboardingService::DEFAULT_VALID_DAYS),
(int) user_id(),
$data['courier_name'],
);
$onboarding->update(['workplatform_expcode' => $code, ...$this->profileValues($data)]);
return redirect()->route('business.courier-onboardings.show', $onboarding)
->with('success', '入驻二维码已生成,发给快递方扫码填写即可。');
}
public function show(CourierOnboarding $onboarding): View
{
$this->ensureReady();
try {
app(\App\Support\CourierQuoteSignatureService::class)->refreshPending();
} catch (\Throwable $exception) {
report($exception);
}
$onboarding->load(['files', 'events.actor', 'quoteVersion.signature']);
$reviewers = app(CourierOnboardingReviewers::class);
$reviewAccess = in_array($onboarding->status, ['submitted', 'first_approved', 'returned'], true)
? $reviewers->access($onboarding, (int) user_id()) : ['allowed' => false, 'message' => '当前申请无需审核。'];
return view('business.pages.courier-payables.courier-onboarding-show', [
'onboarding' => $onboarding,
'formUrl' => $this->formUrl($onboarding),
'signatures' => $this->service->agreementSignatures($onboarding),
'signedAgreement' => $this->service->signedAgreement($onboarding),
'quoteSignature' => $onboarding->quoteSignature(),
'esignReady' => $this->service->esignReady(),
'canFirstReview' => $reviewAccess['allowed'],
'canFinalReview' => $reviewAccess['allowed'],
'reviewAccess' => $reviewAccess,
'reviewerSettings' => $reviewers->settings(),
'isAdmin' => $this->isAdmin(),
'quoteVersions' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? $this->requotes()->versions($onboarding) : collect(),
]);
}
/** 后台变价页:复用入驻报价填写区,以最新版本(或已退回的变价单)为底稿。 */
public function requote(CourierOnboarding $onboarding): View|RedirectResponse
{
$this->ensureReady();
$quoteManagement = route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management']);
if ($onboarding->status !== CourierOnboarding::STATUS_ACTIVATED) {
return redirect()->to($quoteManagement)->with('success', '这家快递还没开通,开通后才能变价。');
}
$requotes = $this->requotes();
$open = $requotes->openChange($onboarding);
if ($open !== null && $open->status === \App\Models\Business\CourierQuoteChange::STATUS_PENDING_REVIEW) {
return redirect()->route('business.courier-quote-changes.show', $open)
->with('success', "已有待审核的变价单 {$open->number},审核完成后再发起新的变价。");
}
$version = $requotes->latestVersion($onboarding);
$payload = $open?->quote_payload ?? $requotes->currentPayload($onboarding);
if ($open === null) {
// 新变价默认从明天起执行。
$payload['quote_effective_date'] = today()->addDay()->toDateString();
}
return view('onboarding.courier-form', [
'onboarding' => $requotes->draftModel($onboarding, $payload),
'token' => '',
'provinces' => CourierOnboarding::PROVINCES,
'fileKinds' => CourierOnboardingFile::KINDS,
'requiredKinds' => CourierOnboardingFile::REQUIRED_KINDS,
'personKinds' => CourierOnboardingFile::PERSON_KINDS,
'personRequiredKinds' => CourierOnboardingFile::PERSON_REQUIRED_KINDS,
'requote' => [
'action' => route('business.courier-onboardings.requote.store', $onboarding),
'back' => $quoteManagement,
'courier' => $onboarding->courier_name,
'signer' => trim(($onboarding->isPerson() ? '' : $onboarding->company_full_name . ' · ') . $onboarding->esign_operator_name . ' ' . $onboarding->esign_account),
'version' => $version,
'change' => $open,
'summary' => old('summary', $open?->summary),
],
]);
}
public function storeRequote(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$this->ensureReady();
$data = $request->validate([
...\App\Http\Controllers\Onboarding\CourierOnboardingForms::quoteRules(),
'summary' => ['required', 'string', 'min:2', 'max:2000'],
'quote_end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:quote_effective_date'],
], [
'summary.required' => '请写一下这次变价的原因或依据,审核和对账时要看。',
'quote_end_date.after_or_equal' => '结束日期不能早于执行日期。',
]);
$change = $this->requotes()->submit($onboarding, $data, (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $change)
->with('success', "变价单 {$change->number} 已提交审核。审核通过后会自动推送到快递的 e签宝签署。");
}
/** @return array<string,?string> */
private function profileValues(array $data): array
{
$values = [];
foreach (array_keys(CourierOnboarding::PROFILE_FIELDS) as $field) {
$values[$field] = filled($data[$field] ?? null) ? trim((string) $data[$field]) : null;
}
return $values;
}
private function requotes(): \App\Support\CourierRequoteService
{
return app(\App\Support\CourierRequoteService::class);
}
public function qr(Request $request, CourierOnboarding $onboarding): Response
{
$this->ensureReady();
$svg = QrCode::svg($this->formUrl($onboarding), 8);
$headers = ['Content-Type' => 'image/svg+xml; charset=utf-8', 'Cache-Control' => 'private, no-store'];
if ($request->boolean('download')) {
$headers['Content-Disposition'] = 'attachment; filename="courier-onboarding-' . $onboarding->number . '.svg"';
}
return response($svg, 200, $headers);
}
public function file(CourierOnboarding $onboarding, CourierOnboardingFile $file): StreamedResponse
{
$this->ensureReady();
abort_unless((int) $file->onboarding_id === (int) $onboarding->id, 404);
abort_unless(Storage::disk('uploads')->exists($file->path), 404, '附件不存在。');
return Storage::disk('uploads')->response($file->path, $file->original_name, [
'Content-Type' => $file->mime ?: 'application/octet-stream',
], request()->boolean('download') ? 'attachment' : 'inline');
}
public function firstApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$note = $request->validate(['note' => ['nullable', 'string', 'max:1000']])['note'] ?? null;
$this->service->firstApprove($onboarding, (int) user_id(), $note);
return $this->back($onboarding, '初审通过,已转财务复审。');
}
public function returnForChanges(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$reason = $request->validate(['reason' => ['required', 'string', 'max:1000']], ['reason.required' => '请写明需要快递方补充或修改的内容。'])['reason'];
$this->service->returnForChanges($onboarding, (int) user_id(), $reason);
return $this->back($onboarding, '已退回补件,快递方用原二维码即可修改后重新提交。');
}
public function reject(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$reason = $request->validate(['reason' => ['required', 'string', 'max:1000']], ['reason.required' => '请填写不予合作的原因。'])['reason'];
$this->service->reject($onboarding, (int) user_id(), $reason);
return $this->back($onboarding, '已标记为不予合作。');
}
public function cancel(CourierOnboarding $onboarding): RedirectResponse
{
$this->service->cancel($onboarding, (int) user_id());
return $this->back($onboarding, '邀请已作废,原二维码不能再打开。');
}
public function extend(CourierOnboarding $onboarding): RedirectResponse
{
$this->service->extend($onboarding, (int) user_id());
return $this->back($onboarding, '链接有效期已延长 30 天。');
}
public function updateInternal(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$data = $request->validate([
'courier_name' => ['nullable', 'string', 'max:100'],
'reconciler' => ['nullable', 'string', 'max:100'],
...CourierOnboarding::profileRules(true),
], CourierOnboarding::profileMessages(), CourierOnboarding::profileAttributes());
if ($request->hasAny(['courier_name', 'reconciler'])) {
// 只提交了其中一项时,另一项沿用原值。
$data['courier_name'] = $request->has('courier_name') ? ($data['courier_name'] ?? null) : $onboarding->courier_name;
$data['reconciler'] = $request->has('reconciler') ? ($data['reconciler'] ?? null) : $onboarding->reconciler;
$this->service->updateInternal($onboarding, $data, (int) user_id());
}
// 表单里带了快递资料字段才更新(只改名称/对账人的旧表单不会清空它们)。
$profile = array_intersect_key($this->profileValues($data), $request->only(array_keys(CourierOnboarding::PROFILE_FIELDS)));
if ($profile !== []) {
$onboarding->update([...$profile, 'updated_by' => (int) user_id()]);
}
return $this->back($onboarding, '内部信息已保存。');
}
/**
* 手动补录 ERP 开通信息(钉钉流程同步不到、或名称对不上时用)。
* 快递资料按快递代码把入驻和工作平台老数据合成一行。
*/
public function erpOpening(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$data = $request->validate([
'workplatform_expcode' => ['nullable', 'string', 'max:64'],
'erp_account_id' => ['nullable', 'string', 'max:64'],
]);
// 快递公司代码在发码时已必填;这里留空表示沿用原代码。填了代码或 ERP账户ID 视为已开通,都空则恢复「待 ERP 开通」。
$code = filled($data['workplatform_expcode'] ?? null) ? strtoupper(trim((string) $data['workplatform_expcode'])) : null;
$accountId = filled($data['erp_account_id'] ?? null) ? trim((string) $data['erp_account_id']) : null;
$opened = $code !== null || $accountId !== null;
$onboarding->update([
'workplatform_expcode' => $code ?? $onboarding->workplatform_expcode,
'erp_account_id' => $accountId,
'erp_opened_at' => $opened ? ($onboarding->erp_opened_at ?? now()) : null,
'erp_opened_source' => $opened ? 'manual' : null,
'updated_by' => (int) user_id(),
]);
return $this->back($onboarding, $opened ? 'ERP 开通信息已保存(快递公司代码 ' . $onboarding->workplatform_expcode . ')。' : '已清空 ERP 开通信息,恢复为「待 ERP 开通」。');
}
public function finalApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$data = $request->validate([
'courier_name' => [$onboarding->courier_name ? 'nullable' : 'required', 'string', 'max:100'],
'reconciler' => ['nullable', 'string', 'max:100'],
'note' => ['nullable', 'string', 'max:1000'],
], ['courier_name.required' => '请填写系统快递名称(需与报价名、发货数据里的快递名称完全一致)。']);
$onboarding = $this->service->finalApprove($onboarding, $data, (int) user_id());
if (! $onboarding->quote_version_id) {
return $this->back($onboarding, '财务复审通过。快递没有填写报价明细,请在报价管理补录后再发起报价确认。');
}
if (! $this->service->esignReady()) {
return $this->back($onboarding, '财务复审通过,首版报价已生成。E 签宝尚未配置,报价确认单暂不能推送;线下签署后可直接开通。');
}
try {
$this->service->pushQuote($onboarding->fresh(), (int) user_id());
} catch (ValidationException $exception) {
$message = collect($exception->errors())->flatten()->first() ?: '推送失败';
CourierOnboardingEvent::create([
'company_id' => $onboarding->company_id, 'onboarding_id' => $onboarding->id, 'event_type' => 'quote_push_failed',
'from_status' => $onboarding->status, 'to_status' => $onboarding->status, 'note' => $message,
'actor_type' => 'user', 'actor_id' => (int) user_id(),
]);
return $this->back($onboarding, '财务复审通过,首版报价已生成,但报价确认单推送失败:' . $message . '。可在右侧重新推送。');
}
return $this->back($onboarding, '财务复审通过,报价确认单已推送到快递的 e签宝,等待对方签署。');
}
public function pushQuote(CourierOnboarding $onboarding): RedirectResponse
{
$this->ensureAdmin();
$this->service->pushQuote($onboarding, (int) user_id());
return $this->back($onboarding, '报价确认单已推送到快递的 e签宝,等待对方签署。');
}
public function syncQuote(CourierOnboarding $onboarding): RedirectResponse
{
$signature = $this->service->syncQuote($onboarding, (int) user_id());
return $this->back($onboarding, '已同步 e签宝状态:' . $signature->statusLabel() . '。');
}
public function signUrl(CourierOnboarding $onboarding): RedirectResponse
{
$url = $this->service->quoteSignUrl($onboarding);
return $this->back($onboarding, '已获取签署链接,签署人用手机打开或扫码即可签署。')->with('quote_sign_url', $url);
}
public function activate(Request $request, CourierOnboarding $onboarding): RedirectResponse
{
$this->ensureAdmin();
$note = $request->validate(['offline_note' => ['nullable', 'string', 'max:1000']])['offline_note'] ?? null;
$this->service->activate($onboarding, (int) user_id(), $note);
return $this->back($onboarding, '快递已开通,快递资料「入驻快递」里可以查到。');
}
private function formUrl(CourierOnboarding $onboarding): string
{
$base = trim((string) config('courier_onboarding.public_url', ''));
if ($base === '') {
return route('courier-onboarding.form', ['token' => $onboarding->token]);
}
return rtrim($base, '/') . route('courier-onboarding.form', ['token' => $onboarding->token], false);
}
private function back(CourierOnboarding $onboarding, string $message): RedirectResponse
{
return redirect()->route('business.courier-onboardings.show', $onboarding)->with('success', $message);
}
private function ensureReady(): void
{
abort_unless($this->service->ready(), 503, '快递入驻数据表尚未完成迁移,请联系管理员执行数据库迁移。');
}
private function isAdmin(): bool
{
return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true);
}
private function ensureAdmin(): void
{
abort_unless($this->isAdmin(), 403, '财务复审和开通需要管理员账号。');
}
}

View File

@ -1,125 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use App\Abstracts\Http\Controller;
use App\Business\CourierProfileData;
use App\Models\Business\CourierPaymentAccount;
use App\Models\Business\CourierProfile;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
/**
* 快递资料在财务系统的维护:编辑主信息(存覆盖值,不回写工作平台),打款信息新增/变更(保存后跳协议签署推送)。
*/
class CourierProfiles extends Controller
{
public function __construct(private readonly CourierProfileData $profiles)
{
}
public function update(Request $request, string $expcode): RedirectResponse
{
$row = $this->profiles->currentRow($expcode);
abort_if($row === null, 404);
$rules = [];
foreach (CourierProfile::EDITABLE as $field => $label) {
$rules[$field] = match (true) {
in_array($field, CourierProfile::BOOLEAN_FIELDS, true) => ['nullable', 'in:0,1'],
in_array($field, CourierProfile::DATE_FIELDS, true) => ['nullable', 'date'],
$field === 'expname' => ['required', 'string', 'max:100'],
default => ['nullable', 'string', 'max:255'],
};
}
$data = $request->validate($rules, ['expname.required' => '快递公司名称不能为空。'], CourierProfile::EDITABLE);
$count = $this->profiles->update($expcode, $row->expname ?? null, $row, $data, (int) user_id());
return redirect($this->pageUrl($expcode, 'logs'))
->with('success', $count > 0 ? "已保存 {$count} 项修改(财务系统维护,不回写工作平台),修改记录见下方操作日志。" : '没有修改任何字段。');
}
/** 打款信息页签里发起:快递公司代码在表单里填。 */
public function storePaymentAccountForCode(Request $request): RedirectResponse
{
$data = $request->validate(
['expcode' => ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z_\-]+$/']],
['expcode.required' => '请填写快递公司代码。', 'expcode.regex' => '快递公司代码只能是字母、数字、下划线或短横线。'],
);
return $this->storePaymentAccount($request, trim($data['expcode']));
}
public function storePaymentAccount(Request $request, string $expcode): RedirectResponse
{
$row = $this->profiles->currentRow($expcode);
if ($row === null) {
throw \Illuminate\Validation\ValidationException::withMessages(['expcode' => "快递资料里没有快递公司代码「{$expcode}」,请核对。"]);
}
$change = $request->input('action') === CourierPaymentAccount::ACTION_CHANGE;
$number = ['required', 'string', 'max:64', 'regex:/^[0-9A-Za-z\- ]+$/'];
$data = $request->validate([
'action' => ['required', 'in:add,change'],
'account_type' => ['required', 'in:' . implode(',', CourierPaymentAccount::ACCOUNT_TYPES)],
'account_name' => ['required', 'string', 'max:255'],
'bank_name' => ['required', 'string', 'max:255'],
'account_number' => $number,
'effective_date' => ['required', 'date'],
'old_account_name' => [$change ? 'required' : 'nullable', 'string', 'max:255'],
'old_account_number' => $change ? $number : ['nullable', 'string', 'max:64'],
'old_stop_date' => [$change ? 'required' : 'nullable', 'date'],
'change_date' => [$change ? 'required' : 'nullable', 'date'],
'note' => ['nullable', 'string', 'max:500'],
], [
'account_number.regex' => '银行账号只能包含数字、字母和短横线。',
'old_account_number.regex' => '原账号只能包含数字、字母和短横线。',
], [
'account_type' => '账户类型', 'account_name' => $change ? '新开户名称' : '开户名称', 'bank_name' => $change ? '新开户银行' : '开户银行',
'account_number' => $change ? '新银行账号' : '银行账号', 'effective_date' => $change ? '新账户启用日期' : '生效日期',
'old_account_name' => '原开户名称', 'old_account_number' => '原账号', 'old_stop_date' => '原账户停止使用日期', 'change_date' => '账户变更日期',
]);
$strip = static fn (?string $value): ?string => $value === null ? null : (string) preg_replace('/\s+/u', '', $value);
$record = CourierPaymentAccount::query()->create([
'company_id' => company_id(),
'expcode' => $expcode,
'courier_name' => $row->expname ?? null,
'action' => $data['action'],
'account_type' => $data['account_type'],
'account_name' => trim($data['account_name']),
'bank_name' => trim($data['bank_name']),
'account_number' => $strip($data['account_number']),
'effective_date' => $data['effective_date'],
'old_account_name' => $change ? trim((string) $data['old_account_name']) : null,
'old_account_number' => $change ? $strip($data['old_account_number']) : null,
'old_stop_date' => $change ? $data['old_stop_date'] : null,
'change_date' => $change ? $data['change_date'] : null,
'note' => $data['note'] ?? null,
'created_by' => (int) user_id(),
'updated_by' => (int) user_id(),
]);
$summary = $change
? "打款信息变更:{$record->old_account_name} → {$record->account_name}({$record->account_type})"
: "打款信息新增:{$record->account_name}({$record->account_type},{$record->bank_name})";
$this->profiles->log($expcode, $change ? 'account_change' : 'account_add', $summary . ',待推送' . ($change ? '收款账户变更' : '收款账户证明') . '协议', null, (int) user_id());
return redirect()->route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'agreement-signing',
'agreement_view' => 'create',
'payment_account' => $record->id,
])->with('success', $summary . '已保存。请核对下方预填的协议内容,补齐签署人信息后推送给快递签署;签署完成后这条打款信息显示为「已生效」。');
}
private function pageUrl(string $expcode, string $detailTab): string
{
return route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'courier-information',
'selected' => $expcode,
'detail_tab' => $detailTab,
]) . '#courier-detail';
}
}

View File

@ -3,12 +3,9 @@
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierQuoteChange;
use App\Models\Business\CourierStockQuoteEntry;
use App\Services\Esign\EsignClient;
use App\Support\CourierQuoteChangeService;
use App\Support\CourierOnboardingService;
use App\Support\CourierQuoteSignatureService;
use App\Support\CourierRequoteService;
use App\Support\QuoteManagementSnapshot;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
@ -22,7 +19,6 @@ class CourierQuoteChanges extends Controller
private readonly CourierQuoteChangeService $service,
private readonly QuoteManagementSnapshot $snapshot,
private readonly CourierQuoteSignatureService $signatureService,
private readonly CourierRequoteService $requotes,
) {
}
@ -39,7 +35,7 @@ class CourierQuoteChanges extends Controller
]);
}
public function create(Request $request): View|RedirectResponse
public function create(Request $request): View
{
$quotes = $this->snapshot->load()['quotes'] ?? [];
abort_if($quotes === [], 422, '尚未导入可用于变更的报价版本。');
@ -47,11 +43,6 @@ class CourierQuoteChanges extends Controller
$selectedId = (string) $request->query('source_quote_id', $quotes[0]['id']);
$source = $this->service->findSourceQuote($selectedId);
// 已入驻快递的报价走后台变价页(可改价格,也可改计费方式),推送按入驻资料签署。
if (empty($source['courier_expcode']) && $request->has('source_quote_id') && ($onboarding = $this->requotes->onboardingForCourier($source['company_name'] ?? null))) {
return redirect()->route('business.courier-onboardings.requote', $onboarding);
}
return view('business.pages.courier-payables.quote-change-editor', $this->editorData(null, $quotes, $source));
}
@ -65,39 +56,20 @@ class CourierQuoteChanges extends Controller
public function show(CourierQuoteChange $quoteChange): View
{
$this->refreshSignatures();
$quoteChange->load(['items', 'events.actor', 'version.signature']);
$stockEntry = $quoteChange->isStockQuote() ? CourierStockQuoteEntry::query()->where('source_change_id', $quoteChange->id)->firstOrFail() : null;
$onboarding = $stockEntry === null && $quoteChange->hasQuotePayload() ? $this->requotes->onboardingForCourier($quoteChange->carrier_name) : null;
$requoteDiff = $onboarding !== null
? $this->requotes->diff($onboarding, $this->requotes->basePayload($onboarding, $quoteChange), $quoteChange->quote_payload)
: null;
return view('business.pages.courier-payables.quote-change-show', [
'change' => $quoteChange,
'stockEntry' => $stockEntry,
'onboarding' => $onboarding,
'requoteDiff' => $requoteDiff,
'reviewWarnings' => $stockEntry === null && $quoteChange->hasQuotePayload() && $quoteChange->status !== CourierQuoteChange::STATUS_APPROVED
? $this->requotes->reviewWarnings($quoteChange, $requoteDiff)
: [],
'voidedBy' => $quoteChange->version?->voided_by ? user_model_class()::query()->find($quoteChange->version->voided_by)?->name : null,
'canReview' => $this->canReview() && ($quoteChange->created_by !== user_id() || config('courier_onboarding.allow_same_reviewer')),
'canReview' => $this->canReview() && $quoteChange->created_by !== user_id(),
'canManageSignature' => $this->canReview(),
'esignConfigured' => EsignClient::configured(),
'esignEnvironment' => (string) config('services.esign.environment', 'sandbox'),
]);
}
public function edit(CourierQuoteChange $quoteChange): View|RedirectResponse
public function edit(CourierQuoteChange $quoteChange): View
{
abort_unless($quoteChange->isEditable() && $quoteChange->created_by === user_id(), 403);
if ($quoteChange->isStockQuote()) {
return redirect()->route('business.courier-stock-quotes.edit', data_get($quoteChange->quote_payload, 'stock_quote_entry_id'));
}
if ($quoteChange->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name))) {
return redirect()->route('business.courier-onboardings.requote', $onboarding);
}
$quotes = $this->snapshot->load()['quotes'] ?? [];
$source = $this->service->findSourceQuote($quoteChange->source_quote_id);
$quoteChange->load('items');
@ -107,7 +79,6 @@ class CourierQuoteChanges extends Controller
public function update(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
{
abort_if($quoteChange->isStockQuote(), 422, '请使用存量报价草稿入口修改。');
$change = $this->service->update($quoteChange, $this->validatedChange($request, false), user_id());
return redirect()->route('business.courier-quote-changes.edit', $change)
@ -122,21 +93,13 @@ class CourierQuoteChanges extends Controller
->with('success', "{$change->number} 已提交审核。");
}
public function approve(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
public function approve(CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
if ($quoteChange->isStockQuote()) {
$request->validate(['stock_rules_confirmed' => ['accepted']], ['stock_rules_confirmed.accepted' => '请先核对原报价、计费规则及实际执行日期。']);
}
$change = $this->service->approve($quoteChange, user_id(), $request->boolean('stock_signature_verified'));
$change = $this->service->approve($quoteChange, user_id());
$message = "审核完成,已生成新报价版本 {$change->version->version_code}。";
if ($change->isStockQuote()) {
return redirect()->route('business.courier-quote-changes.show', $change)
->with('success', $message . ' ' . $change->version->signature_status . '。原报价已保留,未自动发起电子签署或发送提醒。');
}
try {
$this->signatureService->prepareVersion($change->version, user_id());
$message .= ' 签署 PDF 已生成并固定校验值。';
@ -149,84 +112,10 @@ class CourierQuoteChanges extends Controller
$message .= ' 历史生效已登记,但补差核算尚未执行,请查看核算状态和阻断原因。';
}
// 已入驻快递:按入驻资料直接推送到快递的 e签宝。
if ($change->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($change->carrier_name))) {
try {
app(CourierOnboardingService::class)->pushVersion($onboarding, $change->version, (int) user_id());
$message .= " 已推送到快递 e签宝({$onboarding->esign_operator_name} {$onboarding->esign_account}),等待签署。";
} catch (\Throwable $exception) {
report($exception);
$reason = $exception instanceof \Illuminate\Validation\ValidationException ? collect($exception->errors())->flatten()->first() : '推送失败';
$message .= " 推送 e签宝失败:{$reason},可在下方重新推送。";
}
}
return redirect()->route('business.courier-quote-changes.show', $change)
->with('success', $message);
}
public function batchApprove(Request $request): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
$input = $request->validate([
'ids' => ['required', 'array', 'min:1', 'max:100'],
'ids.*' => ['required', 'integer', 'min:1', 'distinct'],
'stock_rules_confirmed' => ['accepted'],
], ['stock_rules_confirmed.accepted' => '请先核对选中报价的原报价、计费规则及实际执行日期。']);
$results = [];
// 历史签署资料需要逐份核对,不接受批量标记已经核验。
$singleRequest = $request->duplicate();
$singleRequest->merge(['stock_signature_verified' => false]);
foreach ($input['ids'] as $id) {
$change = CourierQuoteChange::query()->where('company_id', company_id())->find($id);
if ($change === null) {
$results[] = ['number' => '所选记录', 'success' => false, 'message' => '记录已不存在或不属于当前公司。'];
continue;
}
try {
// 复用单条入口,权限、校验、版本、日志及后续签署流程保持一致。
$response = $this->approve($singleRequest, $change);
$results[] = ['number' => $change->number, 'success' => true, 'message' => $response->getSession()->get('success')];
} catch (\Illuminate\Validation\ValidationException $exception) {
$results[] = ['number' => $change->number, 'success' => false, 'message' => collect($exception->errors())->flatten()->implode(';')];
} catch (\Throwable $exception) {
report($exception);
$results[] = ['number' => $change->number, 'success' => false, 'message' => '处理失败,请查看该单状态并重试。'];
}
}
$passed = count(array_filter($results, static fn ($result) => $result['success']));
return redirect()->route('business.courier-quote-changes.index', ['page' => max(1, (int) $request->input('page', 1))])
->with('success', '批量审核完成:成功 ' . $passed . ' 条,未通过 ' . (count($results) - $passed) . ' 条。')
->with('batch_review_results', $results);
}
/** 已入驻快递的变价版本按入驻资料(重新)推送到快递 e签宝。 */
public function pushToCourier(CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
abort_if($quoteChange->isStockQuote(), 422, '存量报价请先核对历史签署资料,补签通过详情页手动发起。');
$onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name);
abort_if($onboarding === null || $quoteChange->version === null, 404);
app(CourierOnboardingService::class)->pushVersion($onboarding, $quoteChange->version, (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $quoteChange)
->with('success', "{$quoteChange->version->version_code} 已推送到快递 e签宝,等待签署。");
}
/** 作废发错的报价版本(未签完的同时撤回 e签宝 签署任务)。 */
public function void(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
abort_if($quoteChange->version === null, 404);
$data = $request->validate(
['void_reason' => ['required', 'string', 'min:2', 'max:500']],
['void_reason.required' => '请写明作废原因,例如:江苏面单多打了一位。'],
);
$message = $this->requotes->voidVersion($quoteChange->version, trim($data['void_reason']), (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $quoteChange)->with('success', $message);
}
public function returnForChanges(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
@ -353,15 +242,6 @@ class CourierQuoteChanges extends Controller
return $name;
}
private function refreshSignatures(): void
{
try {
$this->signatureService->refreshPending();
} catch (\Throwable $exception) {
report($exception);
}
}
private function canReview(): bool
{
return in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true);

View File

@ -11,7 +11,6 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierQuoteSignatures extends Controller
@ -87,146 +86,6 @@ class CourierQuoteSignatures extends Controller
return \Storage::disk('uploads')->download($path, $fileName, ['Content-Type' => 'application/pdf']);
}
/**
* 批量导出已签署的回签单电子档(签署 PDF + 签署完成证书)为 zip。
* codes 为报价版本号,逗号分隔;不传则导出全部已归档的回签单。
*/
public function export(Request $request): BinaryFileResponse
{
$codes = array_values(array_filter(array_map('trim', explode(',', (string) $request->query('codes', '')))));
$signatures = CourierQuoteSignature::query()
->with('version')
->where('status', CourierQuoteSignature::STATUS_ARCHIVED)
->whereNotNull('signed_pdf_path')
->when($codes !== [], fn ($query) => $query->whereHas('version', fn ($version) => $version->whereIn('version_code', $codes)))
->orderBy('id')
->get();
abort_if($signatures->isEmpty(), 404, '没有可导出的已签署回签单。');
$disk = \Storage::disk('uploads');
$zipPath = tempnam(sys_get_temp_dir(), 'quote-signed-');
$zip = new \ZipArchive();
abort_unless($zip->open($zipPath, \ZipArchive::OVERWRITE) === true, 500, '生成压缩包失败。');
$added = 0;
foreach ($signatures as $signature) {
$version = $signature->version;
$base = $this->safeName(($version->company_name ?: $version->carrier_name) . '-' . $version->version_code);
if ($disk->exists($signature->signed_pdf_path)) {
$zip->addFromString("{$base}-回签单.pdf", $disk->get($signature->signed_pdf_path));
$added++;
}
if ($signature->certificate_path && $disk->exists($signature->certificate_path)) {
$zip->addFromString("{$base}-签署完成证书.pdf", $disk->get($signature->certificate_path));
}
}
$zip->close();
abort_if($added === 0, 404, '回签单归档文件不存在。');
$name = $signatures->count() === 1
? $this->safeName($signatures->first()->version->version_code) . '-回签单电子档.zip'
: '快递报价回签单-' . now()->format('Ymd-His') . '.zip';
return response()->download($zipPath, $name, ['Content-Type' => 'application/zip'])->deleteFileAfterSend();
}
/**
* 导出报价电子表格(xlsx,WPS / Excel 都能打开):只保留「省份」表头和各省价格,
* 不带标题、执行时间、计费说明和备注等文字行。
*/
public function sheet(CourierQuoteVersion $quoteVersion): StreamedResponse
{
$values = json_decode((string) $quoteVersion->values_json, true);
$table = self::quoteTable(is_array($values) ? $values : []);
abort_if($table === [], 404, '这份报价没有可导出的价格表。');
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle(mb_substr($this->safeName($quoteVersion->version_code), 0, 31));
foreach ($table as $rowIndex => $row) {
foreach ($row as $columnIndex => $value) {
$cell = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($columnIndex + 1) . ($rowIndex + 1);
if (is_int($value) || is_float($value)) {
$sheet->setCellValue($cell, $value);
} elseif ($value !== null && $value !== '') {
$sheet->setCellValueExplicit($cell, (string) $value, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);
}
}
}
$lastColumn = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(count($table[0]));
$range = 'A1:' . $lastColumn . count($table);
$sheet->getStyle($range)->getBorders()->getAllBorders()->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN);
$sheet->getStyle($range)->getAlignment()->setHorizontal(\PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER)
->setVertical(\PhpOffice\PhpSpreadsheet\Style\Alignment::VERTICAL_CENTER)->setWrapText(true);
$header = $sheet->getStyle('A1:' . $lastColumn . '1');
$header->getFont()->setBold(true);
$header->getFill()->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)->getStartColor()->setRGB('F3EAD7');
$sheet->getColumnDimension('A')->setWidth(12);
for ($i = 2; $i <= count($table[0]); $i++) {
$sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i))->setWidth(16);
}
$sheet->freezePane('B2');
$fileName = $this->safeName(($quoteVersion->company_name ?: $quoteVersion->carrier_name) . '-' . $quoteVersion->version_code) . '-报价.xlsx';
return response()->streamDownload(function () use ($spreadsheet): void {
(new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output');
}, $fileName, ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']);
}
/**
* 从报价矩阵里截出价格表:从「省份」表头行开始,到第一行只有一格文字(说明、备注)为止。
*
* @param array<int,mixed> $values
* @return list<list<string|int|float|null>>
*/
public static function quoteTable(array $values): array
{
$rows = array_values(array_filter($values, 'is_array'));
$filled = static fn (array $row): int => count(array_filter($row, static fn ($cell): bool => $cell !== null && trim((string) $cell) !== ''));
$start = null;
foreach ($rows as $index => $row) {
$first = trim((string) ($row[0] ?? ''));
if (preg_match('/^(省份|省市|区域|地区|目的地|目的省份)/u', $first) === 1 && $filled($row) >= 2) {
$start = $index;
break;
}
}
if ($start === null) {
foreach ($rows as $index => $row) {
if ($filled($row) >= 2) {
$start = $index;
break;
}
}
}
if ($start === null) {
return [];
}
$table = [];
for ($i = $start; $i < count($rows) && $filled($rows[$i]) >= 2; $i++) {
$table[] = array_values($rows[$i]);
}
$width = 0;
foreach ($table as $row) {
foreach ($row as $column => $cell) {
if ($cell !== null && trim((string) $cell) !== '') {
$width = max($width, $column + 1);
}
}
}
return array_map(static fn (array $row): array => array_pad(array_slice($row, 0, $width), $width, null), $table);
}
private function safeName(string $name): string
{
return trim(preg_replace('~[\\\\/:*?"<>|\s]+~u', '_', $name), '_') ?: 'quote';
}
private function validateAccountForNotice(string $account, string $noticeType): void
{
$valid = $noticeType === '1'

View File

@ -1,214 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierStockQuoteEntry;
use App\Support\CourierStockQuoteBatchService;
use App\Support\CourierStockQuoteEntryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierStockQuoteEntries extends Controller
{
public function __construct(private readonly CourierStockQuoteEntryService $service, private readonly CourierStockQuoteBatchService $batches)
{
}
public function create(): View
{
return view('business.pages.courier-payables.stock-quote-batch', ['batch' => null, 'manifest' => null, 'rows' => [], 'couriers' => [], 'templates' => $this->batches->ruleTemplates()]);
}
public function single(): View
{
return view('business.pages.courier-payables.stock-quote-entry', ['entry' => null, 'couriers' => $this->service->courierOptions()]);
}
public function previewBatch(Request $request): RedirectResponse
{
$data = $request->validate([
'source_quote' => ['required', 'file', 'mimes:xlsx,xls', 'max:20480'],
'quote_type' => ['required', 'string', 'max:64'],
'effective_date' => ['nullable', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d'],
'billing_rules' => ['nullable', 'string', 'max:4000'],
]);
$batch = $this->batches->preview($data['source_quote'], collect($data)->except('source_quote')->all(), (int) user_id());
return redirect()->route('business.courier-stock-quotes.batch', $batch);
}
public function showBatch(string $batch): View
{
$manifest = $this->batches->load($batch, (int) user_id());
$couriers = $manifest['result'] === null ? $this->service->courierOptions() : [];
return view('business.pages.courier-payables.stock-quote-batch', [
'batch' => $batch, 'manifest' => $manifest, 'couriers' => $couriers,
'rows' => $manifest['result'] === null ? $this->batches->rows($manifest, $couriers) : [], 'templates' => [],
]);
}
public function saveBatch(Request $request, string $batch): RedirectResponse|JsonResponse
{
// JSON 请求体不受 max_input_vars 限制,防火墙也可逐字段检查,避免跨字段误匹配。
// 保留旧表单入口,已打开的页面仍由原有防护检查,不豁免任何用户输入。
if ($request->isJson()) {
$selection = $request->json()->all();
} else {
$request->validate(['selection_json' => ['required', 'json', 'max:200000']]);
$selection = json_decode($request->input('selection_json'), true);
}
if (! is_array($selection)) {
throw ValidationException::withMessages(['selection_json' => '请选择本次要录入的工作表。']);
}
foreach (['quote_type', 'summary'] as $field) {
if (is_string($selection[$field] ?? null)) {
$selection[$field] = trim($selection[$field]);
}
}
$data = Validator::make($selection, [
'quote_type' => ['required', 'string', 'max:64'],
'effective_date' => ['nullable', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d'],
'summary' => ['required', 'string', 'min:2', 'max:2000'],
'rule_groups' => ['required', 'array', 'min:1', 'max:10'],
'rule_groups.*' => ['array'],
'rule_groups.*.name' => ['required', 'string', 'max:100'],
'rule_groups.*.rules' => ['nullable', 'string', 'max:4000'],
'entries' => ['required', 'array', 'max:200'],
'entries.*' => ['array'],
'entries.*.selected' => ['required', 'boolean'],
'entries.*.courier_expcode' => ['nullable', 'string', 'max:64'],
'entries.*.courier_name' => ['nullable', 'string', 'max:255'],
'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'],
'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'],
'entries.*.end_date' => ['nullable', 'date_format:Y-m-d'],
'submit_for_review' => ['required', 'boolean'],
'confirmed' => ['accepted_if:submit_for_review,true'],
])->validate();
$this->batches->save($batch, (int) user_id(), $data);
if ($request->expectsJson()) {
return response()->json(['redirect' => route('business.courier-stock-quotes.batch', $batch)]);
}
return redirect()->route('business.courier-stock-quotes.batch', $batch);
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
...$this->metadataRules(),
'courier_expcode' => ['nullable', 'string', 'max:64'],
'courier_name' => ['required_without:courier_expcode', 'nullable', 'string', 'max:255'],
'quote_type' => ['required', 'string', 'max:64'],
'sheet_name' => ['nullable', 'string', 'max:255'],
...$this->fileRules(true),
]);
$entry = $this->service->create($data, (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $entry->change)
->with('success', '存量报价草稿已保存。请核对原报价、计费规则和实际执行日期后提交审核。');
}
public function edit(CourierStockQuoteEntry $stockQuoteEntry): View
{
$this->ensureEditable($stockQuoteEntry);
return view('business.pages.courier-payables.stock-quote-entry', ['entry' => $stockQuoteEntry, 'couriers' => []]);
}
public function update(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse
{
$this->ensureEditable($stockQuoteEntry);
$data = $request->validate([...$this->metadataRules(), ...$this->fileRules(false), 'sheet_name' => ['nullable', 'string', 'max:255'], 'courier_name' => ['sometimes', 'required', 'string', 'max:255']]);
$this->service->update($stockQuoteEntry, $data, (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $stockQuoteEntry->change)->with('success', '草稿已更新,历次原报价及附件已保留。请重新核对后提交审核。');
}
public function batchImage(Request $request, string $batch, int $sheet, int $file): StreamedResponse
{
$manifest = $this->batches->load($batch, (int) user_id());
$attachment = $manifest['sheets'][$sheet]['signed_images'][$file] ?? null;
return $this->attachmentResponse($attachment, $request->boolean('preview'));
}
public function courierNames(Request $request, ShippingOrderRepository $shipping): JsonResponse
{
$data = $request->validate(['q' => ['required', 'string', 'min:2', 'max:64']]);
try {
return response()->json(['items' => $shipping->recentCourierNames(trim($data['q']), 20)]);
} catch (\Throwable $exception) {
return response()->json(['items' => [], 'message' => '发货名称暂不可用,可直接手填。'], 503);
}
}
public function download(Request $request, CourierStockQuoteEntry $stockQuoteEntry, int $file): StreamedResponse
{
abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404);
$attachment = $stockQuoteEntry->files[$file] ?? null;
return $this->attachmentResponse($attachment, $request->boolean('preview'));
}
private function attachmentResponse(?array $attachment, bool $preview): StreamedResponse
{
$disk = Storage::disk('uploads');
abort_if($attachment === null || ! $disk->exists($attachment['path']), 404, '附件不存在。');
$headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'];
if ($preview) {
$mime = $attachment['mime_type'] ?? $disk->mimeType($attachment['path']);
abort_unless(in_array($mime, ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true), 415, '此格式请下载原文件查看。');
return $disk->response($attachment['path'], $attachment['name'], [...$headers, 'Content-Type' => $mime]);
}
return $disk->download($attachment['path'], $attachment['name'], $headers);
}
private function metadataRules(): array
{
return [
'effective_date' => ['required', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
'summary' => ['required', 'string', 'min:2', 'max:2000'],
];
}
private function fileRules(bool $requiresSource): array
{
return [
'source_quote' => [$requiresSource ? 'required' : 'nullable', 'file', 'mimes:xlsx,xls', 'max:20480'],
'confirmation_files' => ['nullable', 'array', 'max:10'],
'confirmation_files.*' => ['file', 'mimes:pdf,doc,docx,jpg,jpeg,png,xlsx,xls,eml,msg', 'max:20480'],
'signed_files' => ['nullable', 'array', 'max:10'],
'signed_files.*' => ['file', 'mimes:pdf,doc,docx,jpg,jpeg,png', 'max:20480'],
];
}
private function ensureEditable(CourierStockQuoteEntry $entry): void
{
abort_unless($entry->company_id === (int) company_id() && $entry->change?->isEditable() && $entry->change->created_by === user_id(), 403);
}
public function periodEnd(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse
{
abort_unless(in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::SUPER_ADMIN, \App\Utilities\SystemAccess::ADMIN], true), 403);
abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404);
$data = $request->validate(['end_date' => ['required', 'date_format:Y-m-d'], 'reason' => ['required', 'string', 'min:2', 'max:1000']]);
$this->service->setPeriodEnd($stockQuoteEntry, $data['end_date'], $data['reason'], (int) user_id());
return redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management'])->with('success', '执行截止日期已保存,报价原文和签署原件保持不变。');
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,6 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\PayablesFinalImporter;
use Modules\EcommerceFinance\Services\PayablesFinalImportRunner;
use Modules\EcommerceFinance\Services\PayablesFinalRegistry;
use Modules\EcommerceFinance\Services\PayablesFieldNormalizer;
use Modules\EcommerceFinance\Services\SupplierPayableImportTemplate;
@ -71,38 +70,6 @@ class PayablesFinalActions extends Controller
: back()->with('success', $message);
}
/** 大文件导入第 1 步:上传文件并建立行缓存(前端随后按片拉取处理)。 */
public function importUpload(Request $request, PayablesFinalImportRunner $runner): JsonResponse
{
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200']]);
$prepared = $runner->prepare(
$request->file('import'),
$this->fields($request),
$this->storeDataset($request),
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
return response()->json($prepared + ['offset' => 0, 'created' => 0, 'updated' => 0, 'done' => false]);
}
/** 大文件导入第 2 步起:每次处理一片,前端拿到进度后继续。 */
public function importChunk(Request $request, PayablesFinalImportRunner $runner): JsonResponse
{
$data = $request->validate([
'token' => ['required', 'string', 'regex:/^[a-f0-9]{32}$/'],
'offset' => ['required', 'integer', 'min:0'],
]);
return response()->json($runner->process(
$data['token'],
(int) $data['offset'],
null,
(int) (company_id() ?: 1),
));
}
public function update(Request $request, string $recordKey): JsonResponse|RedirectResponse
{
$values = $this->normalizer->normalize($this->fields($request), (array) $request->input('values', []));

View File

@ -1,84 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\PlantingLossCalculator;
use Modules\EcommerceFinance\View\PlantingLossPageData;
use Modules\EcommerceFinance\View\PlantingReimbursementPageData;
/** 种菜数据汇总:种菜报销 / 种菜损耗 导出(与页面同一筛选口径)。 */
class PlantingWorkOrders extends Controller
{
public function exportReimbursements(PlantingReimbursementPageData $data)
{
$filters = $data->filters();
return $this->csv(
PlantingReimbursementPageData::TITLE . '-' . $filters['month'],
$data->exportColumns(),
static fn () => $data->rows($filters),
);
}
public function exportLoss(PlantingLossPageData $data)
{
$filters = $data->filters();
$suffix = match ($filters['result']) {
PlantingLossCalculator::RESULT_EXEMPT => '-不扣款拍A发B',
PlantingLossCalculator::RESULT_FREE_GOODS => '-不扣款虚拟条码挂钩',
PlantingLossCalculator::RESULT_UNMATCHED => '-未匹配发货',
'all' => '-全部',
default => '-扣款',
};
// 发运营的拍A发B 名单直接按「种菜报销」的字段导出。
if (request()->query('format') === 'reimbursement') {
return $this->csv(
'拍A发B发运营-' . $filters['month'],
PlantingReimbursementPageData::COLUMNS,
static fn () => $data->rows($filters),
);
}
return $this->csv(
PlantingLossPageData::TITLE . '-' . $filters['month'] . $suffix,
$data->exportColumns(),
static fn () => $data->rows($filters),
);
}
/** @param array<string,string> $columns @param callable():iterable<array<string,mixed>> $rows */
private function csv(string $name, array $columns, callable $rows)
{
$file = tmpfile();
abort_if($file === false, 500, '无法创建导出文件。');
try {
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, array_values($columns));
foreach ($rows() as $row) {
$values = [];
foreach (array_keys($columns) as $key) {
$values[] = $row[$key] ?? '';
}
fputcsv($file, array_map(static function (mixed $value): string {
$value = (string) $value;
// 长数字单号/账号保留文本,避免 Excel 截断;同时阻止公式执行。
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
}, $values));
}
rewind($file);
} catch (\Throwable $exception) {
fclose($file);
report($exception);
abort(503, '聚工单镜像或发货数据未就绪,未生成不完整的导出文件,请稍后重试。');
}
return response()->streamDownload(static function () use ($file): void {
fpassthru($file);
fclose($file);
}, $name . '-' . now()->format('YmdHis') . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@ -1,41 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\SupplierAccessoryFreightMatcher;
use Modules\EcommerceFinance\View\SupplierAccessoryFreightPageData;
class SupplierAccessoryFreight extends Controller
{
public function export(SupplierAccessoryFreightPageData $data)
{
$filters = $data->filters();
$columns = $data->exportColumns();
$file = tmpfile();
abort_if($file === false, 500, '无法创建导出文件。');
try {
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, ['业务月份', '发货记录ID', '仓库', ...array_values($columns)]);
foreach ($data->rows($filters) as $row) {
if ($filters['state'] !== 'all' && $filters['state'] !== $row['state']) continue;
$cells = array_merge($row, ['courier' => ($row['express_name1'] ?? '') ?: ($row['express_name2'] ?? ''),
'freight' => $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2, '.', ''),
'state' => SupplierAccessoryFreightMatcher::STATES[$row['state']], 'deduction' => number_format($row['deduction_cents'] / 100, 2, '.', '')]);
$values = [$filters['month'], $row['source_id'], \Modules\EcommerceFinance\Services\AccessoryReshipMatcher::WAREHOUSE];
foreach ($columns as $key => $label) $values[] = $cells[$key];
fputcsv($file, array_map(static function ($value) {
$value = (string) $value;
// 纯数字标识保留文本,防止 Excel 截断长单号;同时阻止公式执行。
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
}, $values));
}
rewind($file);
} catch (\Throwable $exception) {
fclose($file);
report($exception);
abort(503, '资料读取失败,未生成不完整的扣款明细,请稍后重试。');
}
return response()->streamDownload(function () use ($file) { fpassthru($file); fclose($file); }, SupplierAccessoryFreightPageData::TITLE . '-' . $filters['month'] . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@ -1,44 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\View\SupplierBookkeepingPageData;
class SupplierBookkeeping extends Controller
{
public function export(SupplierBookkeepingPageData $data)
{
$filters = $data->filters();
$columns = $data->exportColumns();
$file = tmpfile();
abort_if($file === false, 500, '无法创建导出文件。');
try {
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, array_values($columns));
foreach ($data->rows($filters) as $row) {
$values = [];
foreach (array_keys($columns) as $key) {
$values[] = $row[$key] ?? '';
}
fputcsv($file, array_map(static function (mixed $value): string {
$value = (string) $value;
// 长数字单号保留文本,避免 Excel 截断;同时阻止公式执行。
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
}, $values));
}
rewind($file);
} catch (\Throwable $exception) {
fclose($file);
report($exception);
abort(503, '聚工单镜像未就绪或读取失败,未生成不完整的厂家记账,请稍后重试。');
}
return response()->streamDownload(static function () use ($file): void {
fpassthru($file);
fclose($file);
}, SupplierBookkeepingPageData::TITLE . '-' . now()->format('YmdHis') . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@ -1,100 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule as ValidationRule;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
use Modules\EcommerceFinance\View\SupplierBookkeepingConfigPageData;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class SupplierBookkeepingConfigs extends Controller
{
public function __construct(private readonly SupplierBookkeepingConfigStore $store) {}
public function store(Request $request)
{
$this->store->save($this->validated($request), $this->actor());
return $this->back('厂家记账配置已新增,厂家记账明细按新配置重新判定。');
}
public function update(Request $request, int $configId)
{
$this->store->save($this->validated($request), $this->actor(), $configId);
return $this->back('厂家记账配置已更新,厂家记账明细按新配置重新判定。');
}
public function destroy(int $configId)
{
$this->store->changeDeleted($configId, true, $this->actor());
return $this->back('配置已删除,该供应商视同不在配置表,按正常记账扣款。');
}
public function restore(int $configId)
{
$this->store->changeDeleted($configId, false, $this->actor());
return $this->back('厂家记账配置已恢复。');
}
public function import(Request $request, SupplierBookkeepingConfigImporter $importer)
{
$request->validate(['file' => ['required', 'file', 'mimes:xlsx', 'max:10240']]);
$path = $request->file('file')->getRealPath();
$result = $this->store->import($importer->read($path), $this->actor(), hash_file('sha256', $path));
return $this->back("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。");
}
public function export(SupplierBookkeepingConfigPageData $data)
{
$rows = $this->store->filtered($data->filters())->get();
$book = new Spreadsheet();
$sheet = $book->getActiveSheet();
$sheet->setTitle('厂家记账配置');
$sheet->fromArray([['供应商', '记账方式', '生效开始日期', '生效结束日期', '原始说明', '创建人', '更新时间', '删除状态']]);
foreach ($rows as $index => $row) {
$values = [$row->supplier_name, Rule::MODES[$row->mode] ?? $row->mode, $row->effective_from, $row->effective_to, $row->source_text, $row->creator, $row->updated_at, $row->deleted_at ? '已删除' : '正常'];
foreach ($values as $column => $value) {
$sheet->setCellValueExplicit([$column + 1, $index + 2], (string) $value, DataType::TYPE_STRING);
}
}
foreach (['A' => 24, 'B' => 20, 'C' => 14, 'D' => 14, 'E' => 44, 'F' => 16, 'G' => 20, 'H' => 10] as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A2');
return response()->streamDownload(function () use ($book): void {
try { (new Xlsx($book))->save('php://output'); } finally { $book->disconnectWorksheets(); }
}, '厂家记账配置.xlsx', ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']);
}
private function validated(Request $request): array
{
return $request->validate([
'supplier_name' => ['required', 'string', 'max:191'],
'mode' => ['required', ValidationRule::in(array_keys(Rule::MODES))],
'effective_from' => ['nullable', 'date_format:Y-m-d'],
'effective_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_from'],
'source_text' => ['nullable', 'string', 'max:255'],
], [], ['supplier_name' => '供应商', 'mode' => '记账方式', 'effective_from' => '生效开始日期', 'effective_to' => '生效结束日期', 'source_text' => '备注']);
}
private function actor(): string
{
return mb_substr((string) user()->name, 0, 191);
}
private function back(string $message)
{
return redirect()->route('business.pages.show', ['section' => 'supplier-payables', 'page' => 'manufacturer-bookkeeping', 'bk_view' => 'config'])->with('bookkeeping_config_message', $message);
}
}

View File

@ -1,161 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\SupplierFreeShippingStore;
use Modules\EcommerceFinance\Services\SupplierFreeShippingWorkbook;
use Modules\EcommerceFinance\View\SupplierManagementPageData;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SupplierManagement extends Controller
{
public function export(
Request $request,
SupplierManagementMirrorRepository $repository,
SupplierManagementPageData $page,
SupplierFreeShippingStore $freeShipping,
): StreamedResponse {
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能导出。');
$filters = $page->filters($request->query());
$columns = SupplierManagementPageData::columns();
$companyId = (int) (company_id() ?: 1);
return response()->streamDownload(static function () use ($repository, $filters, $columns, $freeShipping, $companyId): void {
$csv = fopen('php://output', 'w');
fputs($csv, "\xEF\xBB\xBF");
fputcsv($csv, array_values($columns));
foreach ($repository->exportRows($filters) as $row) {
$row = $freeShipping->decorateRow($row, $companyId);
$values = [];
foreach (array_keys($columns) as $column) {
$value = $column === SupplierManagementPageData::FREE_SHIPPING_COLUMN
? ((bool) ($row->{$column} ?? false) ? '包邮' : '')
: (string) ($row->{$column} ?? '');
if ($column === 'coopersgn') $value = ['1' => '合作', '2' => '不合作'][$value] ?? '未维护';
// Excel 不能执行供应商提供的公式文本,且长编码保留为文本。
if (preg_match('/^[=+\-@\t\r\n]/', ltrim($value)) || preg_match('/^\d{12,}$/', $value)) $value = "'" . $value;
$values[] = $value;
}
fputcsv($csv, $values);
}
fclose($csv);
}, 'supplier_management_' . now()->format('Ymd_His') . '.csv',
['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store']);
}
public function template(SupplierFreeShippingWorkbook $workbook): StreamedResponse
{
return $workbook->template();
}
public function import(
Request $request,
SupplierManagementMirrorRepository $repository,
SupplierFreeShippingStore $freeShipping,
SupplierFreeShippingWorkbook $workbook,
): RedirectResponse {
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]);
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能导入。');
$valuesByCode = $workbook->read($request->file('import'));
$suppliers = $repository->suppliersByCodes(array_keys($valuesByCode));
$matched = [];
foreach ($suppliers as $supplier) {
$code = mb_strtolower(trim((string) ($supplier->scmcode ?? '')));
if ($code !== '') {
$matched[$code][] = $supplier;
}
}
$missing = [];
$ambiguous = [];
$valuesBySupplierId = [];
foreach ($valuesByCode as $code => $isFreeShipping) {
$key = mb_strtolower(trim((string) $code));
if (! isset($matched[$key])) {
$missing[] = (string) $code;
continue;
}
if (count($matched[$key]) !== 1) {
$ambiguous[] = (string) $code;
continue;
}
$valuesBySupplierId[(int) $matched[$key][0]->Id] = $isFreeShipping;
}
if ($ambiguous !== []) {
throw ValidationException::withMessages([
'import' => '以下供应商代码在供应商主档中存在多条记录,未导入:' . implode('、', array_slice($ambiguous, 0, 8)),
]);
}
if ($missing !== []) {
throw ValidationException::withMessages([
'import' => '以下供应商代码未找到,未导入:' . implode('、', array_slice($missing, 0, 8))
. (count($missing) > 8 ? ' 等 ' . count($missing) . ' 条' : ''),
]);
}
$result = $freeShipping->saveMany(
$valuesBySupplierId,
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
'import',
);
return $this->redirectToPage($request)->with(
'supplier_management_message',
"导入完成:新增 {$result['created']} 家供应商维护记录,更新 {$result['updated']} 家供应商维护记录。",
);
}
public function updateFreeShipping(
Request $request,
int $supplier,
SupplierManagementMirrorRepository $repository,
SupplierFreeShippingStore $freeShipping,
): JsonResponse|RedirectResponse {
$request->validate(['value' => ['nullable', 'string', 'in:,包邮']]);
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能编辑。');
abort_if($repository->supplierById($supplier) === null, 404, '供应商不存在。');
$isFreeShipping = $request->input('value') === '包邮';
$freeShipping->save(
$supplier,
$isFreeShipping,
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
'manual',
);
if ($request->expectsJson()) {
return response()->json([
'ok' => true,
'value' => $isFreeShipping ? '包邮' : '',
'display' => $isFreeShipping ? '包邮' : '-',
]);
}
return $this->redirectToPage($request)->with('supplier_management_message', '是否包邮厂家已保存。');
}
private function redirectToPage(Request $request): RedirectResponse
{
$query = array_filter([
...app(SupplierManagementPageData::class)->filters($request->query()),
'page' => max((int) $request->query('page', 1), 1),
], static fn (mixed $value): bool => $value !== '');
$url = route('business.pages.show', [
'company_id' => company_id() ?: 1,
'section' => 'supplier-payables',
'page' => 'supplier-management',
]);
return redirect()->to($url . ($query === [] ? '' : '?' . http_build_query($query)));
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
class SupplierPortal extends Controller
{
public function home(): RedirectResponse
{
return redirect()->route('business.pages.show', [
'company_id' => company_id(),
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
]);
}
}

View File

@ -1,264 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use App\Services\Auth\SupplierPortalAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\SupplierComparisonCsvExporter;
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
use Modules\EcommerceFinance\View\SupplierReconciliationPageData;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
/**
* 供应商对账:对账单模板下载 + 厂家对账单上传(严格按模板校验)。
*/
class SupplierReconciliation extends Controller
{
public function __construct(
private readonly SupplierStatementTemplate $template,
private readonly SupplierStatementStore $statements,
private readonly SupplierReturnConfirmation $returnConfirmations,
private readonly SupplierReconciliationPageData $pageData,
private readonly SupplierReturnCsvExporter $returnCsvExporter,
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
private readonly SupplierComparisonCsvExporter $comparisonCsvExporter,
private readonly SupplierPortalAccess $supplierPortalAccess,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
public function template(): BinaryFileResponse
{
$path = $this->template->path();
abort_unless(is_file($path), 404, '对账单模板不存在。');
return response()->download($path, $this->template->filename());
}
public function upload(Request $request): JsonResponse|RedirectResponse
{
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'],
]);
$supplier = $this->effectiveSupplier($request);
$records = $this->template->parse($request->file('import'), $supplier);
$rows = $this->statements->save(
$supplier,
$records,
(string) $request->file('import')->getClientOriginalName(),
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
$message = "对账单已保存:{$supplier} 共 {$rows} 行。";
return $request->expectsJson()
? response()->json(['message' => $message, 'rows' => $rows])
: back()->with('success', $message);
}
public function confirmReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_CONFIRMED, '已确认该行退货扣减。');
}
public function disputeReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_DISPUTED, '已提交异议,复核通过前该行暂不扣减。');
}
public function confirmAllReturns(Request $request): RedirectResponse
{
[$supplier, $companyId] = $this->returnTarget($request);
$keys = array_values(array_map(
static fn (array $row): string => (string) ($row['_action_key'] ?? ''),
$this->pageData->returnRowsForFilters($supplier, $companyId, $request->all()),
));
$confirmed = $this->returnConfirmations->confirmAll(
$supplier,
$keys,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', "已全部确认:{$confirmed} 行退货扣减生效(异议行保持暂不扣减)。");
}
public function exportReturns(Request $request): StreamedResponse
{
[$supplier, $companyId] = $this->returnTarget($request);
$rows = $this->pageData->returnRowsForFilters($supplier, $companyId, $request->query());
return $this->returnCsvExporter->download($rows, $supplier);
}
public function exportComparison(Request $request): StreamedResponse
{
$request->validate([
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
]);
[$supplier, $companyId] = $this->returnTarget($request);
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->query());
return $this->comparisonCsvExporter->download(
$detail,
$supplier,
trim((string) $request->query('month', '')),
trim((string) $request->query('reconciler', '')),
);
}
public function saveDingTalkRobot(Request $request): RedirectResponse
{
$request->validate([
'supplier' => ['required', 'string', 'max:191'],
'dingtalk_webhook' => ['nullable', 'string', 'max:2048'],
'dingtalk_secret' => ['nullable', 'string', 'max:256'],
'dingtalk_at_all' => ['nullable', 'boolean'],
]);
[$supplier] = $this->returnTarget($request);
$this->dingTalkNotifier->save(
$supplier,
(string) $request->input('dingtalk_webhook', ''),
(string) $request->input('dingtalk_secret', ''),
$request->boolean('dingtalk_at_all'),
);
return back()->with('success', "{$supplier} 的钉钉群机器人已保存。");
}
public function pushDifferences(Request $request): RedirectResponse
{
$request->validate([
'supplier' => ['required', 'string', 'max:191'],
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
'scope' => ['required', 'in:all,row,selected'],
'purchase_no' => ['nullable', 'string', 'max:191'],
'date' => ['nullable', 'string', 'max:32'],
'barcode' => ['nullable', 'string', 'max:191'],
'difference_type' => ['nullable', 'string', 'max:32'],
'selected_rows' => ['nullable', 'array', 'max:600'],
'selected_rows.*' => ['string', 'max:2048'],
]);
[$supplier, $companyId] = $this->returnTarget($request);
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->all());
$differences = array_values(array_filter(
$detail['rows'],
static fn (array $row): bool => ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH,
));
if ($request->input('scope') === 'row') {
$purchaseNo = trim((string) $request->input('purchase_no'));
$date = trim((string) $request->input('date'));
$barcode = trim((string) $request->input('barcode'));
$type = trim((string) $request->input('difference_type'));
$differences = array_values(array_filter(
$differences,
static fn (array $row): bool => (string) ($row['purchaseNo'] ?? '') === $purchaseNo
&& (string) ($row['date'] ?? '') === $date
&& (string) ($row['barcode'] ?? '') === $barcode
&& (string) ($row['type'] ?? '') === $type
));
} elseif ($request->input('scope') === 'selected') {
$selected = [];
foreach ((array) $request->input('selected_rows', []) as $token) {
$row = json_decode((string) $token, true);
if (is_array($row)) {
$selected[$this->differenceRowKey($row)] = true;
}
}
$differences = array_values(array_filter(
$differences,
fn (array $row): bool => isset($selected[$this->differenceRowKey($row)]),
));
}
if ($differences === []) {
return back()->withErrors(['dingtalk_push' => '当前筛选没有可推送的差异。']);
}
try {
$result = $this->dingTalkNotifier->push($supplier, $differences, [
'month' => (string) $request->input('month', ''),
'reconciler' => (string) $request->input('reconciler', ''),
]);
} catch (Throwable $exception) {
return back()->withErrors(['dingtalk_push' => '推送失败:' . $exception->getMessage()]);
}
return back()->with(
'success',
"已推送 {$result['rows']} 条差异到 {$supplier} 钉钉群({$result['messages']} 条消息)。",
);
}
private function markReturn(Request $request, string $status, string $message): RedirectResponse
{
$request->validate(['key' => ['required', 'string', 'max:191']]);
[$supplier, $companyId] = $this->returnTarget($request);
$key = (string) $request->input('key');
$owned = collect($this->pageData->returnRows($supplier, $companyId))
->contains(static fn (array $row): bool => (string) ($row['_action_key'] ?? '') === $key);
abort_unless($owned, 403, '该退货明细不属于当前厂家账号。');
$this->returnConfirmations->mark(
$supplier,
$key,
$status,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', $message);
}
/** @return array{0:string,1:int} */
private function returnTarget(Request $request): array
{
return [$this->effectiveSupplier($request), (int) (company_id() ?: 1)];
}
private function effectiveSupplier(Request $request): string
{
$user = $request->user();
if ($user && SupplierPortalAccess::isSupplierUser($user)) {
$supplier = $this->supplierPortalAccess->supplierFor($user);
abort_if(! $supplier, 403, '当前钉钉账号未绑定有效供应商,请联系财务人员。');
return $supplier;
}
$request->validate(['supplier' => ['required', 'string', 'max:191']]);
$supplier = trim((string) $request->input('supplier'));
abort_if($supplier === '', 422, '请选择厂家。');
return $supplier;
}
/** @param array<string,mixed> $row */
private function differenceRowKey(array $row): string
{
return implode("\0", [
trim((string) ($row['purchaseNo'] ?? $row['purchase_no'] ?? '')),
trim((string) ($row['date'] ?? '')),
trim((string) ($row['barcode'] ?? '')),
trim((string) ($row['type'] ?? '')),
]);
}
}

View File

@ -1,180 +0,0 @@
<?php
namespace App\Http\Controllers\Onboarding;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingFile;
use App\Models\Common\Company;
use App\Support\CourierOnboardingService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
/**
* 快递方扫码打开的免登录入驻填报页。链接里的随机 token 就是凭证,只能读写这一份申请。
*/
class CourierOnboardingForms extends Controller
{
public function __construct(private readonly CourierOnboardingService $service)
{
}
public function show(string $token): View|Response
{
$onboarding = $this->resolve($token);
if ($onboarding === null) {
return $this->unavailable();
}
$onboarding->load('files');
return view('onboarding.courier-form', [
'onboarding' => $onboarding,
'token' => $token,
'provinces' => CourierOnboarding::PROVINCES,
'fileKinds' => CourierOnboardingFile::KINDS,
'requiredKinds' => CourierOnboardingFile::REQUIRED_KINDS,
'personKinds' => CourierOnboardingFile::PERSON_KINDS,
'personRequiredKinds' => CourierOnboardingFile::PERSON_REQUIRED_KINDS,
]);
}
public function update(Request $request, string $token): RedirectResponse|Response
{
$onboarding = $this->resolve($token);
if ($onboarding === null) {
return $this->unavailable();
}
$data = $request->validate($this->rules(), $this->messages());
$files = [];
foreach ((array) $request->file('files', []) as $kind => $uploads) {
$files[(string) $kind] = array_values(array_filter(is_array($uploads) ? $uploads : [$uploads]));
}
$submit = ($data['action'] ?? 'save') === 'submit';
$this->service->saveCourierForm($onboarding, $data, $files, $submit);
return redirect()
->route('courier-onboarding.form', ['token' => $token])
->with('onboarding_success', $submit ? '资料已提交,我们会尽快审核,审核结果请留意对接人通知。' : '资料已暂存,可随时扫码回来继续填写。');
}
private function resolve(string $token): ?CourierOnboarding
{
$onboarding = $this->service->ready() ? $this->service->findByToken($token) : null;
if ($onboarding === null || $onboarding->status === CourierOnboarding::STATUS_CANCELLED) {
return null;
}
// 免登录访问没有当前公司上下文;切到申请所属公司,后续按公司隔离的查询才能命中。
Company::find($onboarding->company_id)?->makeCurrent();
return $onboarding;
}
/**
* 免登录页不走系统的 404 跳转(会跳到登录页),直接给快递方一个能看懂的提示。
*/
private function unavailable(): Response
{
return response()->view('onboarding.courier-unavailable', [], 404);
}
/**
* @return array<string,mixed>
*/
private function rules(): array
{
$text = ['nullable', 'string', 'max:255'];
$phone = ['nullable', 'string', 'regex:/^[0-9+\-\s()]{5,20}$/'];
$money = ['nullable', 'numeric', 'min:0', 'max:100000'];
return [
'remark' => ['nullable', 'string', 'max:2000'],
'action' => ['required', Rule::in(['save', 'submit'])],
'entity_type' => ['nullable', Rule::in(array_keys(CourierOnboarding::ENTITY_TYPES))],
'id_number' => ['nullable', 'string', 'regex:/^\s*\d{17}[\dXx]\s*$/'],
'company_full_name' => $text,
'unified_social_credit_code' => ['nullable', 'string', 'regex:/^[0-9A-Za-z]{18}$/'],
'legal_representative' => ['nullable', 'string', 'max:100'],
'business_address' => $text,
'carrier_brand' => ['nullable', 'string', 'max:50'],
'network_name' => ['nullable', 'string', 'max:100'],
'service_type' => ['nullable', Rule::in(CourierOnboarding::SERVICE_TYPES)],
'contact_name' => ['nullable', 'string', 'max:100'],
'contact_phone' => $phone,
'finance_contact_name' => ['nullable', 'string', 'max:100'],
'finance_contact_phone' => $phone,
'pickup_warehouses' => ['nullable', 'string', 'max:1000'],
'esign_operator_name' => ['nullable', 'string', 'max:100'],
'esign_account' => ['nullable', 'string', 'max:255'],
'bank_account_name' => $text,
'bank_name' => $text,
'bank_account_number' => ['nullable', 'string', 'regex:/^[0-9\s\-]{6,40}$/'],
'private_account_name' => $text,
'private_bank_name' => $text,
'private_account_number' => ['nullable', 'string', 'regex:/^[0-9\s\-]{6,40}$/'],
'invoice_type' => ['nullable', Rule::in(CourierOnboarding::INVOICE_TYPES)],
'tax_rate' => ['nullable', 'numeric', 'min:0', 'max:20'],
'settlement_cycle' => ['nullable', Rule::in(CourierOnboarding::SETTLEMENT_CYCLES)],
...self::quoteRules(),
'remove_files' => ['nullable', 'array'],
'remove_files.*' => ['integer'],
'files' => ['nullable', 'array'],
'files.*' => ['array', 'max:' . CourierOnboardingService::MAX_FILES_PER_KIND],
'files.*.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,gif,heic,heif,pdf,xls,xlsx'],
];
}
/**
* 报价部分的校验规则,后台变价页共用。
*
* @return array<string,mixed>
*/
public static function quoteRules(): array
{
$money = ['nullable', 'numeric', 'min:0', 'max:100000'];
return [
'quote_mode' => ['nullable', Rule::in(array_keys(CourierOnboarding::QUOTE_MODES))],
'volumetric_rule' => ['nullable', Rule::in(CourierOnboarding::VOLUMETRIC_RULES)],
'volumetric_divisor' => ['nullable', 'integer', 'min:1000', 'max:20000'],
'quote_effective_date' => ['nullable', 'date'],
'quote_note' => ['nullable', 'string', 'max:2000'],
'quote_rows' => ['nullable', 'array', 'max:80'],
'quote_rows.*.region' => ['nullable', 'string', 'max:50'],
'quote_rows.*.flat' => ['nullable', 'array', 'max:' . \App\Support\CourierQuoteStructure::MAX_BANDS],
'quote_rows.*.flat.*' => $money,
'quote_rows.*.base' => $money,
'quote_rows.*.rates' => ['nullable', 'array', 'max:' . (\App\Support\CourierQuoteStructure::MAX_TIERS + 1)],
'quote_rows.*.rates.*' => $money,
'quote_flat_bands' => ['nullable', 'string', 'max:200'],
'quote_first_weight' => ['nullable', 'numeric', 'min:0', 'max:1000'],
'quote_rate_tiers' => ['nullable', 'string', 'max:200'],
'quote_then_mode' => ['nullable', Rule::in(['waybill_rate', 'first_additional'])],
'min_weight' => ['nullable', 'numeric', 'min:0', 'max:1000'],
'weight_rounding' => ['nullable', Rule::in(array_keys(\App\Support\CourierQuoteStructure::ROUNDING))],
'per_ticket_surcharge' => ['nullable', 'numeric', 'min:0', 'max:1000'],
];
}
/**
* @return array<string,string>
*/
private function messages(): array
{
return [
'unified_social_credit_code.regex' => '统一社会信用代码应为 18 位数字或字母。',
'contact_phone.regex' => '请填写正确的联系电话。',
'id_number.regex' => '身份证号应为 18 位。',
'finance_contact_phone.regex' => '请填写正确的联系电话。',
'bank_account_number.regex' => '银行账号只能包含数字。',
'private_account_number.regex' => '银行卡号只能包含数字。',
'volumetric_divisor.min' => '抛比系数一般为 5000、6000、8000 等。',
'files.*.*.max' => '单个文件不能超过 10MB。',
'files.*.*.mimes' => '请上传照片、PDF 或 Excel 文件。',
];
}
}

View File

@ -2,7 +2,6 @@
namespace App\Http\Controllers\Portal;
use App\Models\Common\Contact;
use App\Models\Document\Document;
use App\Traits\Charts;
use App\Traits\DateTime;
@ -21,10 +20,6 @@ class Dashboard
{
$contact = user()->contact;
if ($contact instanceof Contact && $contact->type === Contact::VENDOR_TYPE) {
return redirect()->route('portal.supplier-reconciliation.index', ['company_id' => company_id()]);
}
// Redirect user redirect landing page..
if (!$contact) {
return redirect(user()->getLandingPageOfUser());

View File

@ -1,154 +0,0 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Models\Common\Contact;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\View\View;
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
use Modules\EcommerceFinance\View\SupplierReconciliationPageData;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 厂家门户:账号绑定供应商后,仅可查看和操作自己的对账数据。
*/
class SupplierReconciliation extends Controller
{
public function __construct(
private readonly SupplierStatementTemplate $template,
private readonly SupplierStatementStore $statements,
private readonly SupplierReturnConfirmation $returnConfirmations,
private readonly SupplierReconciliationPageData $pageData,
private readonly SupplierReturnCsvExporter $returnCsvExporter,
) {
}
public function index(): View
{
[$contact, $supplier, $companyId] = $this->supplierContext();
$data = $this->pageData->portalData($supplier, $companyId);
return view('portal.supplier-reconciliation.index', array_merge($data, [
'contact' => $contact,
'supplier' => $supplier,
'statementTemplateUrl' => route('portal.supplier-reconciliation.template'),
'uploadUrl' => route('portal.supplier-reconciliation.statement.upload'),
'returnConfirmUrl' => route('portal.supplier-reconciliation.return.confirm'),
'returnDisputeUrl' => route('portal.supplier-reconciliation.return.dispute'),
'returnConfirmAllUrl' => route('portal.supplier-reconciliation.return.confirm-all'),
]));
}
public function template(): BinaryFileResponse
{
$this->supplierContext();
$path = $this->template->path();
abort_unless(is_file($path), 404, '对账单模板不存在。');
return response()->download($path, $this->template->filename());
}
public function upload(Request $request): JsonResponse|RedirectResponse
{
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'],
]);
[, $supplier, $companyId] = $this->supplierContext();
$records = $this->template->parse($request->file('import'), $supplier);
$rows = $this->statements->save(
$supplier,
$records,
(string) $request->file('import')->getClientOriginalName(),
$companyId,
user_id() ? (int) user_id() : null,
);
$message = "对账单已保存:{$supplier} 共 {$rows} 行。";
return $request->expectsJson()
? response()->json(['message' => $message, 'rows' => $rows])
: redirect()
->route('portal.supplier-reconciliation.index', ['company_id' => $companyId])
->with('success', $message);
}
public function confirmReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_CONFIRMED, '已确认该行退货扣减。');
}
public function disputeReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_DISPUTED, '已提交异议,复核通过前该行暂不扣减。');
}
public function confirmAllReturns(Request $request): RedirectResponse
{
[, $supplier, $companyId] = $this->supplierContext();
$keys = array_values(array_map(
static fn (array $row): string => (string) ($row['_action_key'] ?? ''),
$this->pageData->returnRowsForFilters($supplier, $companyId, $request->all()),
));
$confirmed = $this->returnConfirmations->confirmAll(
$supplier,
$keys,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', "已全部确认:{$confirmed} 行退货扣减生效(异议行保持暂不扣减)。");
}
public function exportReturns(Request $request): StreamedResponse
{
[, $supplier, $companyId] = $this->supplierContext();
$rows = $this->pageData->returnRowsForFilters($supplier, $companyId, $request->query());
return $this->returnCsvExporter->download($rows, $supplier);
}
private function markReturn(Request $request, string $status, string $message): RedirectResponse
{
$request->validate(['key' => ['required', 'string', 'max:191']]);
[, $supplier, $companyId] = $this->supplierContext();
$key = (string) $request->input('key');
$owned = collect($this->pageData->returnRows($supplier, $companyId))
->contains(static fn (array $row): bool => (string) ($row['_action_key'] ?? '') === $key);
abort_unless($owned, 403, '该退货明细不属于当前厂家账号。');
$this->returnConfirmations->mark(
$supplier,
$key,
$status,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', $message);
}
/** @return array{0:Contact,1:string,2:int} */
private function supplierContext(): array
{
$contact = user()->contact;
abort_unless(
$contact instanceof Contact
&& $contact->type === Contact::VENDOR_TYPE
&& $contact->enabled,
403,
'当前账号未绑定有效的厂家资料,请联系财务人员。',
);
$supplier = trim((string) $contact->name);
abort_if($supplier === '', 403, '当前厂家账号未设置供应商名称,请联系财务人员。');
return [$contact, $supplier, (int) (company_id() ?: 1)];
}
}

Some files were not shown because too many files have changed in this diff Show More