Compare commits
47 Commits
fix/limeng
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5633ed2430 | |||
| d68548646d | |||
|
|
0ec6f703fe | ||
| a317ab9dea | |||
| db08b2c264 | |||
|
|
32cd0d0a15 | ||
| 8162afa969 | |||
|
|
bcb29aac1c | ||
| d1b751c79c | |||
|
|
fce31f0c09 | ||
| acad1f4577 | |||
| ad8da1264d | |||
| 6470346392 | |||
| f08c600961 | |||
| 144937bfa3 | |||
|
|
5fe92bd05c | ||
| 81bf2bd779 | |||
| cad8369fdb | |||
| 2a2df7451e | |||
|
|
5e05a8728e | ||
|
|
6b044e727b | ||
| 145a62c397 | |||
| 21b29d06eb | |||
| ffa4bbf15f | |||
| 75aac2e5aa | |||
| 0104b9b18e | |||
| 4511f6c71a | |||
| cdabffb3a6 | |||
| 4e174963e8 | |||
| 9b6c3d2477 | |||
| 366dcc1da7 | |||
| 56909bd669 | |||
| 19bb6a8a75 | |||
| dd9cab671b | |||
| 1acd97b9e0 | |||
| c0cc8c7e60 | |||
| bf290d5353 | |||
| 31f6c72bf3 | |||
| bbb967e110 | |||
| a6e0b2ac7d | |||
| 8e4bf99483 | |||
| d735de7864 | |||
| e4d82dab2a | |||
| 17429d65ab | |||
| a124e9906b | |||
| 4c617fd063 | |||
| 528701fdec |
@ -64,6 +64,7 @@
|
||||
|
||||
## 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 使用默认生产入口运行脚本。首次通过本机窗口提供一次服务器密码后,加密保存在本人电脑供后续自动使用,不要求新人准备私钥、执行命令或每次重输密码。已有私钥认证仍兼容。
|
||||
|
||||
29
app/Console/Commands/BackfillWorkOrderWaybillLookup.php
Normal file
29
app/Console/Commands/BackfillWorkOrderWaybillLookup.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
}
|
||||
280
app/Console/Commands/ImportCourierQuoteBillingRules.php
Normal file
280
app/Console/Commands/ImportCourierQuoteBillingRules.php
Normal file
@ -0,0 +1,280 @@
|
||||
<?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')));
|
||||
}
|
||||
}
|
||||
19
app/Console/Commands/RefreshStoreReportPaidSales.php
Normal file
19
app/Console/Commands/RefreshStoreReportPaidSales.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
24
app/Console/Commands/RunAccountBillsExportQueue.php
Normal file
24
app/Console/Commands/RunAccountBillsExportQueue.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,17 @@ class RunAccountBillsExportTask extends Command
|
||||
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);
|
||||
@ -36,6 +47,7 @@ class RunAccountBillsExportTask extends Command
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (($task['status'] ?? '') !== 'queued') return self::SUCCESS;
|
||||
|
||||
$task = $tasks->patch($task, ['status' => 'running', 'started_at' => AccountBillsExportTasks::beijingNow()]);
|
||||
|
||||
@ -110,7 +122,7 @@ class RunAccountBillsExportTask extends Command
|
||||
|
||||
private function findTask(AccountBillsExportTasks $tasks, string $token): ?array
|
||||
{
|
||||
$root = storage_path('app/account-bills-exports');
|
||||
$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) {
|
||||
|
||||
@ -13,12 +13,12 @@ use Throwable;
|
||||
/**
|
||||
* 科目映射表变更后的业绩明细自动重跑队列。
|
||||
*
|
||||
* 一个常驻进程依次处理队列里的「平台 × 月份」;每个实际同步放在独立 PHP 子进程中,
|
||||
* 避免大平台重跑时的内存累积。也可手动执行本命令补跑。
|
||||
* 每轮按预算处理队列里的「平台 × 月份」;实际同步放在独立 PHP 子进程中,
|
||||
* 避免大平台重跑时的内存累积,并让其它同步有机会取得全局锁。
|
||||
*/
|
||||
class RunAccountMappingResync extends Command
|
||||
{
|
||||
protected $signature = 'store-receipt:auto-resync';
|
||||
protected $signature = 'store-receipt:auto-resync {--max-jobs=1 : 每轮最多任务数,0为不限} {--max-seconds=300 : 单个任务结束后检查本轮时间预算}';
|
||||
|
||||
protected $description = '处理科目映射表变更触发的业绩明细重跑队列';
|
||||
|
||||
@ -39,7 +39,10 @@ class RunAccountMappingResync extends Command
|
||||
->where('updated_at', '<', now()->subMinutes(60))
|
||||
->update(['status' => 'pending', 'updated_at' => now()]);
|
||||
|
||||
while (true) {
|
||||
$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')
|
||||
@ -47,6 +50,7 @@ class RunAccountMappingResync extends Command
|
||||
if ($job === null) {
|
||||
break;
|
||||
}
|
||||
$processed++;
|
||||
|
||||
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
|
||||
->where('id', $job->id)
|
||||
@ -58,19 +62,7 @@ class RunAccountMappingResync extends Command
|
||||
]);
|
||||
|
||||
try {
|
||||
$process = Process::fromShellCommandline(
|
||||
implode(' ', [
|
||||
escapeshellarg(PHP_BINARY ?: 'php'),
|
||||
'artisan',
|
||||
'store-receipt:order-match:sync',
|
||||
escapeshellarg((string) $job->month),
|
||||
'--platform=' . escapeshellarg((string) $job->platform),
|
||||
]),
|
||||
base_path()
|
||||
);
|
||||
$process->setTimeout(7200);
|
||||
$process->run();
|
||||
$output = trim($process->getOutput() . "\n" . $process->getErrorOutput());
|
||||
[$succeeded, $output] = $this->runSync($job);
|
||||
|
||||
// 同步期间又保存了映射时,dispatchFor 已把状态改回 pending;
|
||||
// 这里只在仍为 running 时写 done,不能吞掉新请求。
|
||||
@ -78,9 +70,9 @@ class RunAccountMappingResync extends Command
|
||||
->where('id', $job->id)
|
||||
->where('status', 'running')
|
||||
->update([
|
||||
'status' => $process->isSuccessful() ? 'done' : 'failed',
|
||||
'status' => $succeeded ? 'done' : 'failed',
|
||||
'message' => mb_substr(
|
||||
$output !== '' ? $output : ($process->isSuccessful() ? 'OK' : '同步命令执行失败'),
|
||||
$output !== '' ? $output : ($succeeded ? 'OK' : '同步命令执行失败'),
|
||||
0,
|
||||
1000
|
||||
),
|
||||
@ -104,4 +96,17 @@ class RunAccountMappingResync extends Command
|
||||
|
||||
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())];
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,12 +6,14 @@ 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 表示直到完成}';
|
||||
|
||||
@ -25,6 +27,25 @@ 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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -34,6 +34,9 @@ class SyncStoreReceiptProductCost extends Command
|
||||
/** 虚拟条码(信誉订单/虚拟订单)不是真实商品,不计入商品成本。 */
|
||||
private const VIRTUAL_SKU_PREFIX = 'X0000';
|
||||
|
||||
/** 套餐 / 服务占位编码:不是实物商品,商品成本不取数。 */
|
||||
private const EXCLUDED_SKUS = ['TCXJ', 'FUWU'];
|
||||
|
||||
private const REMARK_NO_WAREHOUSE = '未匹配仓库简称';
|
||||
|
||||
private const REMARK_NO_PRICE = '未匹配商品单价';
|
||||
@ -54,6 +57,9 @@ class SyncStoreReceiptProductCost extends Command
|
||||
|
||||
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';
|
||||
|
||||
@ -114,10 +120,15 @@ class SyncStoreReceiptProductCost extends Command
|
||||
// 账单侧取数:业绩明细里本月(可按平台限定)的账单行,一行 = 一个店铺的一个订单。
|
||||
// 只取「收入金额有数据且不为 0」的账单行:收入为 0 的行是补出来的优惠/冲减/无收款账单行,
|
||||
// 没有对应的收款商品,不参与商品成本。
|
||||
// 「种菜金额」有数据(>0)的账单行整条排除,不参与匹配。
|
||||
$billQuery = $mirror->table(self::SOURCE_TABLE)
|
||||
->where('report_month', $reportMonth)
|
||||
->whereNotNull('income_amount')
|
||||
->where('income_amount', '!=', 0);
|
||||
->where('income_amount', '!=', 0)
|
||||
->where(static function ($query): void {
|
||||
$query->whereNull('planting_amount')
|
||||
->orWhere('planting_amount', '<=', 0);
|
||||
});
|
||||
if ($platform !== '') {
|
||||
$billQuery->where('platform', $platform);
|
||||
}
|
||||
@ -168,6 +179,7 @@ class SyncStoreReceiptProductCost extends Command
|
||||
|
||||
/** @var array<string,list<array{sku:string,qty:float,amount:float,warehouse:string,is_main:bool,row_key:string}>> $lines */
|
||||
$lines = [];
|
||||
$excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'";
|
||||
foreach (self::SOURCES as $source) {
|
||||
try {
|
||||
$rows = $mirror->select(
|
||||
@ -181,7 +193,8 @@ class SyncStoreReceiptProductCost extends Command
|
||||
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%'"
|
||||
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());
|
||||
@ -236,11 +249,14 @@ class SyncStoreReceiptProductCost extends Command
|
||||
$prices = $this->weightedAverageUnitPrices($mirror, $reportMonth);
|
||||
// 月加权平均单价匹配不到时,用「线上代发价格」(采购单价 / 钉钉代发单价)兜底。
|
||||
$fallbackPrices = $this->onlineDaifaUnitPrices($mirror);
|
||||
// 代发价格也没有时,再用普通商品资料里的商品成本价兜底。
|
||||
$catalogPrices = $this->catalogCostPrices($mirror);
|
||||
$this->info(sprintf(
|
||||
'仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d',
|
||||
'仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d,商品资料成本价条目:%d',
|
||||
count($shortNames['by_provider']),
|
||||
count($prices),
|
||||
count($fallbackPrices)
|
||||
count($fallbackPrices),
|
||||
count($catalogPrices)
|
||||
));
|
||||
|
||||
$this->pruneTarget($connection, $reportMonth, $platform);
|
||||
@ -254,13 +270,12 @@ class SyncStoreReceiptProductCost extends Command
|
||||
$missingPriceRows = 0;
|
||||
$missingWarehouseRows = 0;
|
||||
$fallbackPriceRows = 0;
|
||||
$catalogPriceRows = 0;
|
||||
$mismatchRows = 0;
|
||||
|
||||
foreach ($billByCore as $core => $bill) {
|
||||
foreach ($this->costLines($lines[$core] ?? []) as $line) {
|
||||
$short = $shortNames['by_warehouse'][$line['warehouse']]
|
||||
?? $shortNames['by_provider'][$line['warehouse']]
|
||||
?? '';
|
||||
$short = $shortNames['by_provider'][$line['warehouse']] ?? '';
|
||||
$price = $short !== '' ? ($prices[$short . "\0" . $line['sku']] ?? null) : null;
|
||||
$priceSource = $price === null ? null : self::PRICE_SOURCE_WEIGHTED;
|
||||
// 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。
|
||||
@ -272,6 +287,15 @@ class SyncStoreReceiptProductCost extends Command
|
||||
$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);
|
||||
@ -342,9 +366,10 @@ class SyncStoreReceiptProductCost extends Command
|
||||
$this->info(sprintf(' 商品数量合计:%s', number_format($quantityTotal, 2)));
|
||||
$this->info(sprintf(' 商品成本合计:%s', number_format($costTotal, 2)));
|
||||
$this->info(sprintf(
|
||||
' 未匹配仓库简称:%d 行;线上代发价格兜底:%d 行;仍未匹配商品单价:%d 行;数量与单价口径不一致(不计成本):%d 行',
|
||||
' 未匹配仓库简称:%d 行;线上代发价格兜底:%d 行;商品资料成本价兜底:%d 行;仍未匹配商品单价:%d 行;数量与单价口径不一致(不计成本):%d 行',
|
||||
$missingWarehouseRows,
|
||||
$fallbackPriceRows,
|
||||
$catalogPriceRows,
|
||||
$missingPriceRows,
|
||||
$mismatchRows
|
||||
));
|
||||
@ -442,10 +467,14 @@ class SyncStoreReceiptProductCost extends Command
|
||||
|
||||
$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;
|
||||
return $expanded === [] ? [] : $expanded;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -459,7 +488,6 @@ class SyncStoreReceiptProductCost extends Command
|
||||
private function warehouseShortNames(): array
|
||||
{
|
||||
$byProvider = [];
|
||||
$byWarehouse = [];
|
||||
foreach (DB::table(self::WAREHOUSE_MAPPING_TABLE)
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']) as $mapping) {
|
||||
$provider = trim((string) $mapping->warehouse_provider);
|
||||
@ -468,19 +496,30 @@ class SyncStoreReceiptProductCost extends Command
|
||||
if ($short === '') {
|
||||
continue;
|
||||
}
|
||||
if ($warehouse !== '' && ! isset($byWarehouse[$warehouse])) {
|
||||
$byWarehouse[$warehouse] = $short;
|
||||
}
|
||||
if ($provider === '') {
|
||||
continue;
|
||||
}
|
||||
// 只认「仓储方 + 仓库含主仓」的那条:发货仓库是仓储方级别的名称,
|
||||
// 次品仓 / 销退仓 / 客退仓这些行的简称(多为「-不看」)不能套到主仓发货上。
|
||||
$isMain = str_contains($warehouse, '主仓');
|
||||
if ($isMain || ! isset($byProvider[$provider])) {
|
||||
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, 'by_warehouse' => $byWarehouse];
|
||||
return ['by_provider' => $byProvider];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -589,6 +628,32 @@ class SyncStoreReceiptProductCost extends Command
|
||||
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);
|
||||
|
||||
172
app/Console/Commands/VerifyErpDailyBillCoverage.php
Normal file
172
app/Console/Commands/VerifyErpDailyBillCoverage.php
Normal file
@ -0,0 +1,172 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@ -89,9 +89,10 @@ class AccountMappingActions extends Controller
|
||||
}
|
||||
|
||||
$values = $this->payloadValues($request);
|
||||
$this->store->update($id, $values, $this->userId());
|
||||
$after = $this->store->update($id, $values, $this->userId());
|
||||
|
||||
$hint = $this->dispatchResync([(string) ($before['platform'] ?? ''), (string) ($values['platform'] ?? '')]);
|
||||
$hint = AccountMappingStore::requiresReceiptResync($before, $after)
|
||||
? $this->dispatchResync([(string) ($before['platform'] ?? ''), (string) ($after['platform'] ?? '')]) : '';
|
||||
|
||||
return response()->json(['message' => '已保存。' . $hint] + $this->payload());
|
||||
}
|
||||
@ -241,10 +242,7 @@ 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 = array_values(array_unique(array_map(
|
||||
static fn (array $row): string => (string) ($row['platform'] ?? ''),
|
||||
$parsed['rows']
|
||||
)));
|
||||
$platforms = $result['resync_platforms'];
|
||||
$message .= $this->dispatchResync($platforms);
|
||||
|
||||
return response()->json(['message' => $message, 'result' => $result] + $this->payload());
|
||||
|
||||
@ -105,11 +105,15 @@ class Pages extends Controller
|
||||
'拼多多',
|
||||
'京东账单',
|
||||
'京喜账单',
|
||||
'京东网银钱包',
|
||||
'抖音',
|
||||
'得物',
|
||||
];
|
||||
|
||||
/** 业绩明细不纳入的收款账户:京东网银钱包的收、支均不取。 */
|
||||
private const RECEIPT_MATCH_EXCLUDED_PLATFORMS = [
|
||||
'京东网银钱包',
|
||||
];
|
||||
|
||||
/**
|
||||
* 京东「直营补贴款」备注适用的京喜店铺(前缀)。
|
||||
*
|
||||
@ -136,6 +140,12 @@ class Pages extends Controller
|
||||
'未匹配',
|
||||
];
|
||||
|
||||
/** 差额按实收/应付口径兜底时使用的备注;这部分行才需要判断复杂差额原因。 */
|
||||
private const RECEIPT_BASIS_FALLBACK_REMARKS = [
|
||||
'订单实付按卖家实收核对',
|
||||
'订单实付按应付/已付核对',
|
||||
];
|
||||
|
||||
/** 业绩明细「异常标注」筛选项,取值与同步命令写入的标注一致。 */
|
||||
private const RECEIPT_REMARK_OPTIONS = [
|
||||
'账单无单号',
|
||||
@ -279,14 +289,21 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
private static function receiptReasonNotBasisMarkedSql(): string
|
||||
{
|
||||
return "COALESCE(remark, '') NOT IN ('订单实付按卖家实收核对', '订单实付按应付/已付核对')";
|
||||
}
|
||||
|
||||
/**
|
||||
* 「差额异常备注」原因(不落库):说明差额为什么产生,一行最多一个,按顺序取第一条命中。
|
||||
* 未匹配行 → 订单实付未匹配(细分原因见「未匹配原因」列);已匹配但基准金额=0 → 订单实付取数为0;
|
||||
* 基准金额没有减掉优惠/冲减时,差额正好等于对应项。差额按有效值判定(未匹配按基准金额=0)。
|
||||
* 「差额异常备注」基础原因(不落库),按页面优先级排列。
|
||||
*
|
||||
* @return array<string,string> 备注文字 => SQL 条件(已按顺序排除前面命中的行)
|
||||
* 这里保留可重叠的原始条件;需要“一行只取第一个原因”时走
|
||||
* receiptDifferenceRemarkIndexSql(),由单次 CASE 按顺序命中,避免把
|
||||
* 前序大段条件重复拼进后续每一条,拖慢筛选联动。
|
||||
*
|
||||
* @return array<string,string> 备注文字 => 原始 SQL 条件
|
||||
*/
|
||||
private static function receiptDifferenceRemarkConditions(): array
|
||||
private static function receiptDifferenceRemarkBaseRules(): array
|
||||
{
|
||||
$basis = self::differenceBasis();
|
||||
$column = $basis['column'];
|
||||
@ -355,9 +372,13 @@ class Pages extends Controller
|
||||
$jdSofiaGuobu = '(' . $jdSofiaPay . ' - COALESCE(income_amount, 0)) > 0.005';
|
||||
$rules['差额=订单实付包含商家承担优惠和以旧换新国补'] = $jdSofiaGuard . ' AND ' . $jdSofiaGuobu;
|
||||
$rules['差额=订单实付包含商家承担优惠'] = $jdSofiaGuard . ' AND NOT (' . $jdSofiaGuobu . ')';
|
||||
// 拼多多全额退款:先于通用「未减优惠/冲减」判定,避免抵减项把退款单误标成其他原因。
|
||||
$pddFullRefundHit = "platform = '拼多多'"
|
||||
. ' AND ABS(COALESCE(income_amount, 0) + COALESCE(refund_amount, 0)) <= 0.02';
|
||||
$rules['订单实付含已退款部分'] = $pddFullRefundHit;
|
||||
// C 方案:这三条是"通用等式档"——口径(卖家实收 / 应付已付)能对上账单的行让位给口径兜底
|
||||
// (归零 + 口径标识),只有口径也对不上时才显示这三档,避免把口径差标成一堆"未减优惠/冲减"。
|
||||
$notBasisMarked = "COALESCE(remark, '') NOT IN ('订单实付按卖家实收核对', '订单实付按应付/已付核对')";
|
||||
$notBasisMarked = self::receiptReasonNotBasisMarkedSql();
|
||||
$rules[$label . '未减冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(discount_amount, 0))) < 0.005 AND " . $notBasisMarked;
|
||||
$rules[$label . '未减优惠'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(offset_amount, 0))) < 0.005 AND " . $notBasisMarked;
|
||||
$rules[$label . '未减优惠和冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - COALESCE(income_amount, 0)) < 0.005 AND " . $notBasisMarked;
|
||||
@ -393,29 +414,66 @@ class Pages extends Controller
|
||||
. ' OR ' . $refundGapHit($refundEvidence['cancelled_sum'])
|
||||
. ' OR ' . $refundGapNetHit($refundEvidence['refund_sum'])
|
||||
. ' OR ' . $refundFullHit($refundEvidence['refund_sum'])
|
||||
. ') ELSE 0 END = 1) OR ' . $taoRefundHit;
|
||||
. ') ELSE 0 END = 1) OR ' . $taoRefundHit . ' OR ' . $pddFullRefundHit;
|
||||
|
||||
// 拼多多部分退款:账单收入高于聚水潭买家实付,且扣除优惠/冲减后仍有正向小额差额,
|
||||
// 与「多多支付立减」等平台支付类补贴一致;只标注原因,不改差额和收入。
|
||||
$pddPaymentDiscount = '(' . $effective . ' - COALESCE(discount_amount, 0) - COALESCE(offset_amount, 0))';
|
||||
$rules['差额=平台支付立减'] = "platform = '拼多多'"
|
||||
. " AND match_status = '已匹配'"
|
||||
. ' AND ' . $effective . ' > 0.005'
|
||||
. ' AND COALESCE(refund_amount, 0) < -0.005'
|
||||
. ' AND ' . $pddPaymentDiscount . ' > 0.005'
|
||||
. ' AND ' . $pddPaymentDiscount . ' <= 20.005'
|
||||
. ' AND ' . $notBasisMarked;
|
||||
|
||||
// 1688 先采后付订单的订单实付包含平台红包补贴,但支付宝实际到账是扣除补贴后的现金;
|
||||
// 仅对已匹配、负差额、无优惠/冲减/退款且当前没有口径标识的行标注,不改差额金额。
|
||||
$rules['订单实付含平台红包补贴'] = "platform = '淘系支付宝'"
|
||||
. " AND (store_name LIKE '新佰广%' OR store_name LIKE '阿里巴巴%' OR store_name LIKE '1688%')"
|
||||
. ' AND match_status = \'已匹配\''
|
||||
. ' AND ' . $effective . ' < -0.005'
|
||||
. ' AND ABS(COALESCE(discount_amount, 0)) < 0.005'
|
||||
. ' AND ABS(COALESCE(offset_amount, 0)) < 0.005'
|
||||
. ' AND ABS(COALESCE(refund_amount, 0)) < 0.005'
|
||||
. ' AND COALESCE(paid_amount, 0) > COALESCE(income_amount, 0) + 0.005'
|
||||
. ' AND ' . $notBasisMarked;
|
||||
|
||||
// 京喜分位尾差:具体原因都没命中时,1–3 分钱按四舍五入尾差标注。
|
||||
$rules['差额=分位四舍五入尾差'] = "platform = '京喜账单' AND ABS(" . $effective . ') <= 0.0301 AND ' . $notBasisMarked;
|
||||
|
||||
$conditions = [];
|
||||
$seen = [];
|
||||
foreach ($rules as $label => $condition) {
|
||||
$condition = 'ABS(' . $effective . ') > 0.005 AND (' . $condition . ')';
|
||||
$conditions[$label] = $seen === [] ? $condition : $condition . ' AND NOT (' . implode(' OR ', $seen) . ')';
|
||||
$seen[] = '(' . $condition . ')';
|
||||
return $rules;
|
||||
}
|
||||
|
||||
// 兜底档:有差额但没命中任何原因 →「差额待核实」(与页面渲染的兜底备注保持一致)。
|
||||
$conditions['差额待核实'] = 'ABS(' . $effective . ') > 0.005 AND NOT (' . implode(' OR ', $seen) . ') AND ' . $notBasisMarked;
|
||||
/** @return list<string> 差额异常备注的下拉顺序(含兜底档) */
|
||||
private static function receiptDifferenceRemarkLabels(): array
|
||||
{
|
||||
return array_merge(array_keys(self::receiptDifferenceRemarkBaseRules()), ['差额待核实']);
|
||||
}
|
||||
|
||||
return $conditions;
|
||||
/**
|
||||
* 差额异常备注的优先级序号(1 开始;0=无备注)。
|
||||
*
|
||||
* 单次 CASE 命中后即停止,筛选统计与备注筛选共用同一口径。相比把
|
||||
* 18 条条件逐条带上前序 NOT 再分别 SUM,避免重复计算退款等关联子查询。
|
||||
*/
|
||||
private static function receiptDifferenceRemarkIndexSql(): string
|
||||
{
|
||||
$effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))';
|
||||
$sql = 'CASE WHEN ABS(' . $effective . ') <= 0.005 THEN 0';
|
||||
$index = 1;
|
||||
foreach (self::receiptDifferenceRemarkBaseRules() as $condition) {
|
||||
$sql .= ' WHEN (' . $condition . ') THEN ' . $index;
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $sql . ' WHEN ' . self::receiptReasonNotBasisMarkedSql() . ' THEN ' . $index . ' ELSE 0 END';
|
||||
}
|
||||
|
||||
/**
|
||||
* 该单的聚水潭应付(主单优先 pay_amount)是否大于账单收入(= 含国补/补贴类差额的口径)。
|
||||
*
|
||||
* 与 receiptDifferenceRemarkConditions() 里的聚水潭应付款子查询同口径;按单号缓存应付金额,
|
||||
* 与 receiptDifferenceRemarkBaseRules() 里的聚水潭应付款子查询同口径;按单号缓存应付金额,
|
||||
* 页面渲染与导出逐行调用时不会对同一单重复查询。
|
||||
*/
|
||||
private static function receiptJdSubsidyPayExceedsIncome(string $orderNo, float $income): bool
|
||||
@ -486,6 +544,13 @@ class Pages extends Controller
|
||||
return abs((float) ($row->paid_amount ?? 0) - (float) ($row->product_amount ?? 0)) < 0.02;
|
||||
}
|
||||
|
||||
private static function is1688StoreName(string $storeName): bool
|
||||
{
|
||||
return str_starts_with($storeName, '新佰广')
|
||||
|| str_starts_with($storeName, '阿里巴巴')
|
||||
|| str_starts_with($storeName, '1688');
|
||||
}
|
||||
|
||||
/** 单行的「差额异常备注」(与筛选条件同一套口径,页面与导出共用)。 */
|
||||
/**
|
||||
* 页面口径的「有效差额」SQL:口径兜底行(备注为空、但能对上实收/应付)按 0 计。
|
||||
@ -493,18 +558,36 @@ class Pages extends Controller
|
||||
*/
|
||||
private static function receiptEffectiveDifferenceSql(): string
|
||||
{
|
||||
// 注意:要把「差额待核实」这条兜底规则排除掉,否则它本身就是"都没命中",
|
||||
// 会让这里永远不成立(口径兜底行就归不了零)。
|
||||
$rules = self::receiptDifferenceRemarkConditions();
|
||||
unset($rules['差额待核实']);
|
||||
$ruleUnion = '(' . implode(' OR ', array_map(
|
||||
static fn (string $condition): string => '(' . $condition . ')',
|
||||
$rules
|
||||
)) . ')';
|
||||
// 备注序号 > 0 表示命中了明确原因;只有口径兜底且没有明确原因时才归零。
|
||||
$indexSql = self::receiptDifferenceRemarkIndexSql();
|
||||
return "CASE WHEN remark IN (" . self::receiptBasisFallbackSqlList() . ')'
|
||||
. " AND COALESCE({$indexSql}, 0) = 0 THEN 0"
|
||||
. ' ELSE ' . self::receiptDefaultDifferenceSql() . ' END';
|
||||
}
|
||||
|
||||
return "CASE WHEN remark IN ('订单实付按卖家实收核对','订单实付按应付/已付核对')"
|
||||
. " AND NOT COALESCE({$ruleUnion}, 0) THEN 0"
|
||||
. ' ELSE COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) END';
|
||||
/** 未命中复杂原因时,差额的默认口径。 */
|
||||
private static function receiptDefaultDifferenceSql(): string
|
||||
{
|
||||
return 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))';
|
||||
}
|
||||
|
||||
/**
|
||||
* 口径兜底行的差额修正量:只对「实收/应付核对」行计算复杂规则,
|
||||
* 避免把京东、京喜、得物、淘系退款等关联子查询放进整月汇总的每一行。
|
||||
*/
|
||||
private static function receiptFallbackDifferenceAdjustmentSql(): string
|
||||
{
|
||||
return 'CASE WHEN COALESCE(' . self::receiptDifferenceRemarkIndexSql() . ', 0) = 0 THEN -('
|
||||
. self::receiptDefaultDifferenceSql() . ') ELSE 0 END';
|
||||
}
|
||||
|
||||
/** @return string SQL 的字符串字面量列表 */
|
||||
private static function receiptBasisFallbackSqlList(): string
|
||||
{
|
||||
return implode(', ', array_map(
|
||||
static fn (string $remark): string => "'" . str_replace("'", "''", $remark) . "'",
|
||||
self::RECEIPT_BASIS_FALLBACK_REMARKS
|
||||
));
|
||||
}
|
||||
|
||||
private static function receiptDifferenceRemark(object $row): ?string
|
||||
@ -533,6 +616,11 @@ class Pages extends Controller
|
||||
if (abs($product) < 0.005) {
|
||||
return ($row->planting_amount ?? null) !== null ? $label . '取数为0(种菜)' : $label . '取数为0';
|
||||
}
|
||||
// 拼多多全额退款优先于其它抵减等式:退款后实际净收款为 0,不应再落成优惠/冲减异常。
|
||||
if (($row->platform ?? '') === '拼多多'
|
||||
&& abs($income + (float) ($row->refund_amount ?? 0)) <= 0.02) {
|
||||
return '订单实付含已退款部分';
|
||||
}
|
||||
// 得物负差额:差额与联合营销费未让利尾数吻合(营销费支出 + 差额恰为整数)→「订单实付未减联合营销费」。
|
||||
// 放在通用「未减冲减/优惠」等式之前:得物这类行的差额常恰好等于冲减(营销费)金额,需优先给出具体项备注。
|
||||
if (($row->platform ?? '') === '得物' && $difference < -0.005) {
|
||||
@ -639,6 +727,17 @@ class Pages extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// 拼多多部分退款:差额扣除优惠/冲减后,剩余部分与平台支付立减一致。
|
||||
$pddPaymentDiscount = $difference - $discount - $offset;
|
||||
if (($row->platform ?? '') === '拼多多'
|
||||
&& ! $basisMarked
|
||||
&& $difference > 0.005
|
||||
&& (float) ($row->refund_amount ?? 0) < -0.005
|
||||
&& $pddPaymentDiscount > 0.005
|
||||
&& $pddPaymentDiscount <= 20.005) {
|
||||
return '差额=平台支付立减';
|
||||
}
|
||||
|
||||
// 原因优先:上面的规则都没命中时,如果这行的口径(卖家实收 / 应付已付)能对上账单,
|
||||
// 就按口径兜底——差额按 0 展示、口径标识在「匹配原因」列,不再当异常。
|
||||
$rawRemark = trim((string) ($row->remark ?? ''));
|
||||
@ -646,6 +745,18 @@ class Pages extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1688 先采后付红包补贴:订单实付包含平台补贴,支付宝到账只含现金部分。
|
||||
// 先只作为已知原因标注,不改变差额金额或税务收入。
|
||||
if (($row->platform ?? '') === '淘系支付宝'
|
||||
&& $difference < -0.005
|
||||
&& self::is1688StoreName((string) ($row->store_name ?? ''))
|
||||
&& abs($discount) < 0.005
|
||||
&& abs($offset) < 0.005
|
||||
&& abs((float) ($row->refund_amount ?? 0)) < 0.005
|
||||
&& (float) ($row->paid_amount ?? 0) > $income + 0.005) {
|
||||
return '订单实付含平台红包补贴';
|
||||
}
|
||||
|
||||
// 京喜分位尾差:具体原因都没命中时,1–3 分钱按四舍五入尾差标注。
|
||||
if (($row->platform ?? '') === '京喜账单' && abs($difference) <= 0.0301) {
|
||||
return '差额=分位四舍五入尾差';
|
||||
@ -1213,13 +1324,15 @@ class Pages extends Controller
|
||||
// 镜像表本身就是「一个店铺 + 一个订单号 + 一个商品金额」的行口径,直接统计即可。
|
||||
$base = static fn () => DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
|
||||
->table('store_receipt_order_match_rows')
|
||||
->where('report_month', $month);
|
||||
->where('report_month', $month)
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
|
||||
|
||||
// 计数与合计合并为一次全表扫描:重筛选项(如按备注筛选)下少一次全表重算。
|
||||
$aggQuery = $base();
|
||||
$applyFilters($aggQuery);
|
||||
// 口径兜底行(备注为空、口径对上)的差额按 0 计——与页面行展示、差额筛选同一套判定。
|
||||
$differenceExpr = self::receiptEffectiveDifferenceSql();
|
||||
$differenceBaseExpr = self::receiptDefaultDifferenceSql();
|
||||
$fallbackSql = self::receiptBasisFallbackSqlList();
|
||||
$agg = $aggQuery->selectRaw("
|
||||
COUNT(*) as total,
|
||||
SUM(payment_count) as payments,
|
||||
@ -1230,13 +1343,24 @@ class Pages extends Controller
|
||||
SUM(planting_amount) as planting_total,
|
||||
SUM(product_amount) as goods_total,
|
||||
SUM(paid_amount) as paid_total,
|
||||
SUM({$differenceExpr}) as difference_total,
|
||||
SUM({$differenceBaseExpr}) as difference_base,
|
||||
SUM(CASE WHEN remark IN ({$fallbackSql}) THEN 1 ELSE 0 END) as basis_fallback_rows,
|
||||
SUM(CASE WHEN payment_count > 1 THEN 1 ELSE 0 END) as multi_pay,
|
||||
SUM(CASE WHEN income_amount = 0 THEN 1 ELSE 0 END) as zero_income,
|
||||
SUM(CASE WHEN match_status = '已匹配' THEN 1 ELSE 0 END) as matched,
|
||||
SUM(CASE WHEN match_status = '未匹配' THEN 1 ELSE 0 END) as unmatched
|
||||
")->first();
|
||||
$total = (int) ($agg->total ?? 0);
|
||||
$differenceTotal = (float) ($agg->difference_base ?? 0);
|
||||
if ((int) ($agg->basis_fallback_rows ?? 0) > 0) {
|
||||
$adjustmentQuery = $base();
|
||||
$applyFilters($adjustmentQuery);
|
||||
$adjustment = $adjustmentQuery
|
||||
->whereIn('remark', self::RECEIPT_BASIS_FALLBACK_REMARKS)
|
||||
->selectRaw('SUM(' . self::receiptFallbackDifferenceAdjustmentSql() . ') as adjustment')
|
||||
->first();
|
||||
$differenceTotal += (float) ($adjustment->adjustment ?? 0);
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'month' => $month,
|
||||
@ -1255,7 +1379,7 @@ class Pages extends Controller
|
||||
'planting_total' => round((float) ($agg->planting_total ?? 0), 2),
|
||||
'goods_total' => round((float) ($agg->goods_total ?? 0), 2),
|
||||
'paid_total' => round((float) ($agg->paid_total ?? 0), 2),
|
||||
'difference_total' => round((float) ($agg->difference_total ?? 0), 2),
|
||||
'difference_total' => round($differenceTotal, 2),
|
||||
'per_page' => $limit,
|
||||
];
|
||||
|
||||
@ -1266,7 +1390,8 @@ class Pages extends Controller
|
||||
now()->addHours(6),
|
||||
static function () use ($connection, $month): array {
|
||||
$maker = static fn () => DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->where('report_month', $month);
|
||||
->where('report_month', $month)
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
|
||||
|
||||
return [
|
||||
'platforms' => $maker()->whereNotNull('platform')->distinct()->orderBy('platform')->pluck('platform')->all(),
|
||||
@ -1277,17 +1402,20 @@ class Pages extends Controller
|
||||
);
|
||||
$stats['selected_platforms'] = $platformFilter;
|
||||
$stats['selected_stores'] = $storeFilter;
|
||||
$stats['platform_options'] = array_values(array_unique(array_merge(
|
||||
$stats['platform_options'] = array_values(array_diff(
|
||||
array_unique(array_merge(
|
||||
self::RECEIPT_PLATFORM_OPTIONS,
|
||||
array_map('strval', $filterOptions['platforms'] ?? []),
|
||||
)));
|
||||
)),
|
||||
self::RECEIPT_MATCH_EXCLUDED_PLATFORMS,
|
||||
));
|
||||
$stats['store_options'] = array_values(array_unique(array_merge(
|
||||
$storeFilter,
|
||||
array_map('strval', $filterOptions['stores'] ?? []),
|
||||
)));
|
||||
$stats['status_options'] = self::RECEIPT_STATUS_OPTIONS;
|
||||
// 差额异常备注下拉:只保留「差额为什么产生」的原因备注(按差额公式判定)
|
||||
$stats['remark_options'] = array_values(array_keys(self::receiptDifferenceRemarkConditions()));
|
||||
$stats['remark_options'] = self::receiptDifferenceRemarkLabels();
|
||||
// 未匹配原因下拉:未匹配行在库里的原因标注
|
||||
$stats['unmatched_reason_options'] = self::unmatchedReasonOptions();
|
||||
|
||||
@ -1360,20 +1488,21 @@ class Pages extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$differenceConditions = self::receiptDifferenceRemarkConditions();
|
||||
$differenceLabels = array_values(array_intersect($remarkFilter, array_keys($differenceConditions)));
|
||||
if ($differenceLabels === []) {
|
||||
$labels = self::receiptDifferenceRemarkLabels();
|
||||
$indexes = [];
|
||||
foreach ($remarkFilter as $label) {
|
||||
$index = array_search($label, $labels, true);
|
||||
if ($index !== false) {
|
||||
$indexes[] = (int) $index + 1;
|
||||
}
|
||||
}
|
||||
if ($indexes === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->where(function ($group) use ($differenceLabels, $differenceConditions): void {
|
||||
foreach ($differenceLabels as $index => $label) {
|
||||
$method = $index === 0 ? 'where' : 'orWhere';
|
||||
$group->{$method}(function ($sub) use ($label, $differenceConditions): void {
|
||||
$sub->whereRaw($differenceConditions[$label]);
|
||||
});
|
||||
}
|
||||
});
|
||||
$query->whereRaw(
|
||||
self::receiptDifferenceRemarkIndexSql() . ' IN (' . implode(', ', array_unique($indexes)) . ')'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1432,7 +1561,8 @@ class Pages extends Controller
|
||||
{
|
||||
$query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
|
||||
->table('store_receipt_order_match_rows')
|
||||
->where('report_month', $month);
|
||||
->where('report_month', $month)
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
|
||||
|
||||
foreach ($filters as $key => $values) {
|
||||
if ($key === 'difference_state' || $values === [] || $values === '') {
|
||||
@ -1498,6 +1628,7 @@ class Pages extends Controller
|
||||
{
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$months = DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
|
||||
->select('report_month')
|
||||
->distinct()
|
||||
->orderByDesc('report_month')
|
||||
@ -1561,22 +1692,196 @@ class Pages extends Controller
|
||||
// 联动统计要跑几次全表聚合,短时间内重复请求(页面加载 + 下拉刷新)直接吃缓存
|
||||
$cacheKey = 'performance-details-facets:' . md5(serialize([$month, $filters, $orderNo]));
|
||||
|
||||
// 数据按天同步,缓存 10 分钟:大幅降低大平台(拼多多等)的联动等待。
|
||||
return response()->json(cache()->remember($cacheKey, now()->addSeconds(600), function () use ($month, $filters, $orderNo) {
|
||||
// 数据按天同步;联动统计会扫全月,缓存 1 小时,避免相同筛选反复等待。
|
||||
return response()->json(cache()->remember($cacheKey, now()->addHour(), function () use ($month, $filters, $orderNo) {
|
||||
return [
|
||||
'month' => substr($month, 0, 7),
|
||||
'facets' => [
|
||||
'platform' => $this->receiptOrderMatchFacet($month, 'platform', $filters, $orderNo, self::RECEIPT_PLATFORM_OPTIONS),
|
||||
'store_name' => $this->receiptOrderMatchFacet($month, 'store_name', $filters, $orderNo),
|
||||
'match_status' => $this->receiptOrderMatchFacet($month, 'match_status', $filters, $orderNo, self::RECEIPT_STATUS_OPTIONS),
|
||||
'remark' => $this->receiptOrderMatchFacet($month, 'remark', $filters, $orderNo, array_keys(self::receiptDifferenceRemarkConditions())),
|
||||
'unmatched_reason' => $this->receiptOrderMatchFacet($month, 'unmatched_reason', $filters, $orderNo, self::unmatchedReasonOptions()),
|
||||
'difference_state' => $this->receiptDifferenceStateFacet($month, $filters, $orderNo),
|
||||
],
|
||||
'facets' => $this->receiptOrderMatchFacets($month, $filters, $orderNo),
|
||||
];
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次请求生成全部筛选联动计数。
|
||||
*
|
||||
* 多个筛选项如果共用同一组“其它条件”,只查一次再一起聚合。页面当前先按
|
||||
* 差额备注筛选时,原先 6 次重复全表条件判断会合并成 2 次查询。
|
||||
*
|
||||
* @param array<string,list<string>> $filters
|
||||
* @return array<string,array{values:list<string>,counts:array<string,int>}>
|
||||
*/
|
||||
private function receiptOrderMatchFacets(string $month, array $filters, string $orderNo): array
|
||||
{
|
||||
$facetOrder = [
|
||||
'platform' => self::RECEIPT_PLATFORM_OPTIONS,
|
||||
'store_name' => [],
|
||||
'match_status' => self::RECEIPT_STATUS_OPTIONS,
|
||||
'remark' => self::receiptDifferenceRemarkLabels(),
|
||||
'unmatched_reason' => self::unmatchedReasonOptions(),
|
||||
'difference_state' => ['有差额', '无差额'],
|
||||
];
|
||||
|
||||
// “统计某个筛选本身”时排除该筛选;条件完全相同的筛选项合并成一个查询。
|
||||
$groups = [];
|
||||
foreach (array_keys($facetOrder) as $column) {
|
||||
$signature = $filters;
|
||||
unset($signature[$column]);
|
||||
$signature = array_filter(
|
||||
$signature,
|
||||
static fn ($values): bool => $values !== [] && $values !== ''
|
||||
);
|
||||
ksort($signature);
|
||||
$key = md5(serialize([$orderNo, $signature]));
|
||||
$groups[$key]['filters'] = $signature;
|
||||
$groups[$key]['columns'][] = $column;
|
||||
}
|
||||
|
||||
$facets = [];
|
||||
foreach ($groups as $group) {
|
||||
if ($group['filters'] === []) {
|
||||
foreach ($group['columns'] as $column) {
|
||||
$facets[$column] = $column === 'difference_state'
|
||||
? $this->receiptDifferenceStateFacet($month, $filters, $orderNo)
|
||||
: $this->receiptOrderMatchFacet($month, $column, $filters, $orderNo, $facetOrder[$column]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$counts = $this->receiptOrderMatchFilteredFacetCounts(
|
||||
$month,
|
||||
$group['filters'],
|
||||
$orderNo,
|
||||
$group['columns']
|
||||
);
|
||||
foreach ($group['columns'] as $column) {
|
||||
$columnCounts = $counts[$column] ?? [];
|
||||
$ordered = array_values(array_intersect($facetOrder[$column], array_keys($columnCounts)));
|
||||
$rest = array_values(array_diff(array_keys($columnCounts), $ordered));
|
||||
sort($rest, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
$facets[$column] = [
|
||||
'values' => array_merge($ordered, $rest),
|
||||
'counts' => $columnCounts,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return array_replace(array_fill_keys(array_keys($facetOrder), ['values' => [], 'counts' => []]), $facets);
|
||||
}
|
||||
|
||||
/**
|
||||
* 共用同一组筛选条件时,一次分组查询算出多个筛选项的计数。
|
||||
*
|
||||
* @param array<string,list<string>> $filters
|
||||
* @param list<string> $columns
|
||||
* @return array<string,array<string,int>>
|
||||
*/
|
||||
private function receiptOrderMatchFilteredFacetCounts(string $month, array $filters, string $orderNo, array $columns): array
|
||||
{
|
||||
$labels = self::receiptDifferenceRemarkLabels();
|
||||
$basisMarks = ['订单实付按卖家实收核对', '订单实付按应付/已付核对'];
|
||||
$remarkIndexes = [];
|
||||
foreach ((array) ($filters['remark'] ?? []) as $label) {
|
||||
$index = array_search($label, $labels, true);
|
||||
if ($index !== false) {
|
||||
$remarkIndexes[] = (int) $index + 1;
|
||||
}
|
||||
}
|
||||
$differenceValues = array_map('strval', (array) ($filters['difference_state'] ?? []));
|
||||
$wantDifference = in_array('有差额', $differenceValues, true);
|
||||
$wantNoDifference = in_array('无差额', $differenceValues, true);
|
||||
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$inner = DB::connection($connection)
|
||||
->table('store_receipt_order_match_rows')
|
||||
->where('report_month', $month)
|
||||
->select(['platform', 'store_name', 'match_status', 'remark'])
|
||||
->selectRaw(self::receiptDefaultDifferenceSql() . ' AS base_difference')
|
||||
->selectRaw(self::receiptDifferenceRemarkIndexSql() . ' AS reason_index');
|
||||
|
||||
foreach ($filters as $key => $values) {
|
||||
if ($key === 'remark' || $key === 'difference_state' || $values === []) {
|
||||
continue;
|
||||
}
|
||||
if ($key === 'unmatched_reason') {
|
||||
$this->applyReceiptUnmatchedReasonFilter($inner, (array) $values);
|
||||
continue;
|
||||
}
|
||||
$inner->whereIn($key, $values);
|
||||
}
|
||||
if (trim($orderNo) !== '') {
|
||||
$inner->where('online_order_no', 'like', '%' . addcslashes(trim($orderNo), '%_\\') . '%');
|
||||
}
|
||||
|
||||
$outer = DB::connection($connection)
|
||||
->query()
|
||||
->fromSub($inner, 'facet_rows');
|
||||
if ($remarkIndexes !== []) {
|
||||
$outer->whereIn('reason_index', array_values(array_unique($remarkIndexes)));
|
||||
}
|
||||
if ($wantDifference !== $wantNoDifference) {
|
||||
$effective = 'CASE WHEN remark IN ('
|
||||
. implode(', ', array_map(static fn (string $mark): string => "'" . $mark . "'", $basisMarks))
|
||||
. ') AND reason_index = 0 THEN 0 ELSE base_difference END';
|
||||
$outer->whereRaw('ABS(' . $effective . ')' . ($wantDifference ? ' > 0.005' : ' <= 0.005'));
|
||||
}
|
||||
|
||||
$rows = $outer
|
||||
->select(['platform', 'store_name', 'match_status', 'remark', 'reason_index', 'base_difference'])
|
||||
->selectRaw('COUNT(*) AS total')
|
||||
->groupBy(['platform', 'store_name', 'match_status', 'remark', 'reason_index', 'base_difference'])
|
||||
->get();
|
||||
|
||||
$counts = [
|
||||
'platform' => [],
|
||||
'store_name' => [],
|
||||
'match_status' => [],
|
||||
'remark' => [],
|
||||
'unmatched_reason' => [],
|
||||
'difference_state' => [],
|
||||
];
|
||||
$wanted = array_fill_keys($columns, true);
|
||||
foreach ($rows as $row) {
|
||||
$total = (int) ($row->total ?? 0);
|
||||
$platform = (string) ($row->platform ?? '');
|
||||
$store = (string) ($row->store_name ?? '');
|
||||
$status = (string) ($row->match_status ?? '');
|
||||
$rawRemark = trim((string) ($row->remark ?? ''));
|
||||
$reasonIndex = (int) ($row->reason_index ?? 0);
|
||||
$reasonLabel = $reasonIndex > 0 ? ($labels[$reasonIndex - 1] ?? null) : null;
|
||||
|
||||
if (isset($wanted['platform']) && $platform !== '') {
|
||||
$counts['platform'][$platform] = ($counts['platform'][$platform] ?? 0) + $total;
|
||||
}
|
||||
if (isset($wanted['store_name']) && $store !== '') {
|
||||
$counts['store_name'][$store] = ($counts['store_name'][$store] ?? 0) + $total;
|
||||
}
|
||||
if (isset($wanted['match_status']) && $status !== '') {
|
||||
$counts['match_status'][$status] = ($counts['match_status'][$status] ?? 0) + $total;
|
||||
}
|
||||
if (isset($wanted['remark']) && $reasonLabel !== null) {
|
||||
$counts['remark'][$reasonLabel] = ($counts['remark'][$reasonLabel] ?? 0) + $total;
|
||||
}
|
||||
if (isset($wanted['unmatched_reason'])) {
|
||||
$unmatchedReason = ($status === '未匹配' && $rawRemark !== '')
|
||||
? $rawRemark
|
||||
: (in_array($rawRemark, $basisMarks, true) ? $rawRemark : null);
|
||||
if ($unmatchedReason !== null) {
|
||||
$counts['unmatched_reason'][$unmatchedReason] = ($counts['unmatched_reason'][$unmatchedReason] ?? 0) + $total;
|
||||
}
|
||||
}
|
||||
if (isset($wanted['difference_state'])) {
|
||||
$difference = (float) ($row->base_difference ?? 0);
|
||||
if ($reasonIndex === 0 && in_array($rawRemark, $basisMarks, true)) {
|
||||
$difference = 0.0;
|
||||
}
|
||||
$differenceLabel = abs($difference) > 0.005 ? '有差额' : '无差额';
|
||||
$counts['difference_state'][$differenceLabel] = ($counts['difference_state'][$differenceLabel] ?? 0) + $total;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个筛选项的可选值与行数:应用除该筛选项以外的所有条件。
|
||||
*
|
||||
@ -1615,23 +1920,24 @@ class Pages extends Controller
|
||||
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
|
||||
}
|
||||
|
||||
// 差额异常备注联动:5 条原因一次查询算完(原来是 5 次全表 count,下拉要等 1 秒多)
|
||||
$differenceCounts = [];
|
||||
$differenceLabels = array_keys(self::receiptDifferenceRemarkConditions());
|
||||
if ($column === 'remark') {
|
||||
$expressions = [];
|
||||
foreach (array_values(self::receiptDifferenceRemarkConditions()) as $index => $condition) {
|
||||
$expressions[] = 'SUM(CASE WHEN (' . $condition . ') THEN 1 ELSE 0 END) as c' . $index;
|
||||
}
|
||||
$row = (clone $query)->selectRaw(implode(', ', $expressions))->first();
|
||||
foreach ($differenceLabels as $index => $label) {
|
||||
$differenceCounts[$label] = (int) ($row->{'c' . $index} ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
if ($column === 'remark') {
|
||||
// 一行只按优先级命中一次,再按序号分组,避免 18 条规则各跑一遍全表。
|
||||
$indexSql = self::receiptDifferenceRemarkIndexSql();
|
||||
$rows = (clone $query)
|
||||
->selectRaw($indexSql . ' AS reason_index')
|
||||
->selectRaw('COUNT(*) AS total')
|
||||
->groupByRaw('reason_index')
|
||||
->pluck('total', 'reason_index');
|
||||
$labels = self::receiptDifferenceRemarkLabels();
|
||||
foreach ($rows as $index => $total) {
|
||||
$label = $labels[(int) $index - 1] ?? null;
|
||||
if ($label !== null && (int) $total > 0) {
|
||||
$counts[$label] = (int) $total;
|
||||
}
|
||||
}
|
||||
} elseif ($column === 'unmatched_reason') {
|
||||
// 未匹配原因是页面级判定(只统计未匹配行,按库里的 remark 分组)
|
||||
if ($column === 'unmatched_reason') {
|
||||
$base = clone $query;
|
||||
$rows = $query->where('match_status', '未匹配')
|
||||
->select('remark')
|
||||
@ -1650,8 +1956,7 @@ class Pages extends Controller
|
||||
$counts[$basisMark] = (int) $marker;
|
||||
}
|
||||
}
|
||||
} elseif ($column !== 'remark') {
|
||||
// 差额异常备注是页面级判定,不再按库里的标注列分组
|
||||
} else {
|
||||
$rows = $query->select($column)
|
||||
->selectRaw('count(*) as total')
|
||||
->whereNotNull($column)
|
||||
@ -1662,11 +1967,6 @@ class Pages extends Controller
|
||||
$counts[(string) $value] = (int) $total;
|
||||
}
|
||||
}
|
||||
foreach ($differenceCounts as $label => $total) {
|
||||
if ((int) $total > 0) {
|
||||
$counts[$label] = (int) $total;
|
||||
}
|
||||
}
|
||||
|
||||
$ordered = array_values(array_intersect($order, array_keys($counts)));
|
||||
$rest = array_values(array_diff(array_keys($counts), $ordered));
|
||||
@ -1748,6 +2048,8 @@ class Pages extends Controller
|
||||
'offset_amount' => ['offset_amount', '冲减金额', 'money'],
|
||||
'refund_amount' => ['refund_amount', '退款金额', 'money'],
|
||||
'planting_amount' => ['planting_amount', '种菜金额', 'money'],
|
||||
'planting_reimbursement_amount' => ['planting_reimbursement_amount', '种菜报销', 'money'],
|
||||
'planting_unreimbursed_amount' => ['planting_unreimbursed_amount', '种菜未报销', 'money'],
|
||||
'product_amount' => ['product_amount', '商品金额', 'money'],
|
||||
'paid_amount' => ['paid_amount', '订单实付金额', 'money'],
|
||||
'difference' => ['difference', '差额', 'money'],
|
||||
@ -1758,6 +2060,7 @@ class Pages extends Controller
|
||||
[, $month] = $this->receiptOrderMatchMonths();
|
||||
|
||||
$query = DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
|
||||
->when($month !== null, static fn ($builder) => $builder->where('report_month', $month));
|
||||
|
||||
$platforms = $this->receiptOrderMatchFilterValues($request->query('platform'));
|
||||
@ -1906,8 +2209,6 @@ class Pages extends Controller
|
||||
'online_order_no' => trim((string) request('online_order_no', '')),
|
||||
'product_code' => trim((string) request('product_code', '')),
|
||||
'warehouse_short_name' => trim((string) request('warehouse_short_name', '')),
|
||||
'missing_price' => request()->boolean('missing_price') ? '1' : '',
|
||||
'include_excluded' => request()->boolean('include_excluded') ? '1' : '',
|
||||
];
|
||||
|
||||
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
|
||||
@ -1927,7 +2228,9 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
$applyFilters = static function ($query, bool $excludedWarehouseOnly = false) use ($filters): void {
|
||||
// 页面不再提供「只看未匹配单价 / 包含-不看仓」两个开关:默认全部数据展示,
|
||||
// 已匹配与未匹配单价、所有仓库(含「-不看」仓)都纳入统计与列表。
|
||||
$applyFilters = static function ($query) use ($filters): void {
|
||||
$query->where('report_month', $filters['month'] . '-01');
|
||||
if ($filters['platform'] !== '') {
|
||||
$query->where('platform', $filters['platform']);
|
||||
@ -1940,41 +2243,22 @@ class Pages extends Controller
|
||||
if ($filters['warehouse_short_name'] !== '') {
|
||||
$query->where('warehouse_short_name', $filters['warehouse_short_name']);
|
||||
}
|
||||
if ($filters['missing_price'] === '1') {
|
||||
$query->whereNull('unit_price');
|
||||
}
|
||||
// 仓库映射表里标「不看」的仓(次品 / 销退 / 虚拟 / 信誉 / 京东云仓等)默认不展示:
|
||||
// 这些仓在商品月加权平均单价里本来就没有价,展示出来只会让「未匹配单价」虚高。
|
||||
if ($excludedWarehouseOnly) {
|
||||
$query->where('warehouse_short_name', 'like', '%-不看%');
|
||||
|
||||
return;
|
||||
}
|
||||
if ($filters['include_excluded'] !== '1') {
|
||||
$query->where(static function ($inner): void {
|
||||
$inner->whereNull('warehouse_short_name')
|
||||
->orWhere('warehouse_short_name', 'not like', '%-不看%');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$countQuery = $base();
|
||||
$applyFilters($countQuery);
|
||||
$total = (int) $countQuery->count();
|
||||
|
||||
$excludedQuery = $base();
|
||||
$applyFilters($excludedQuery, true);
|
||||
$excludedRows = (int) $excludedQuery->count();
|
||||
|
||||
$aggQuery = $base();
|
||||
$applyFilters($aggQuery);
|
||||
$agg = $aggQuery->selectRaw('
|
||||
SUM(CASE WHEN unit_price IS NOT NULL THEN product_quantity ELSE 0 END) as quantity_total,
|
||||
SUM(CASE WHEN unit_price IS NOT NULL THEN product_cost ELSE 0 END) as cost_total,
|
||||
SUM(CASE WHEN unit_price IS NULL THEN 1 ELSE 0 END) as missing_price_rows,
|
||||
SUM(CASE WHEN unit_price IS NULL THEN product_quantity ELSE 0 END) as missing_price_quantity,
|
||||
SUM(CASE WHEN unit_price IS NULL AND (remark IS NULL OR remark NOT LIKE \'%口径不一致%\') THEN 1 ELSE 0 END) as missing_price_rows,
|
||||
SUM(CASE WHEN unit_price IS NULL AND (remark IS NULL OR remark NOT LIKE \'%口径不一致%\') THEN product_quantity ELSE 0 END) as missing_price_quantity,
|
||||
SUM(CASE WHEN warehouse_short_name IS NULL THEN 1 ELSE 0 END) as missing_warehouse_rows,
|
||||
SUM(CASE WHEN price_source LIKE \'线上代发价格%\' THEN 1 ELSE 0 END) as fallback_price_rows,
|
||||
SUM(CASE WHEN price_source = \'普通商品资料成本价\' THEN 1 ELSE 0 END) as catalog_price_rows,
|
||||
SUM(CASE WHEN remark LIKE \'%口径不一致%\' THEN 1 ELSE 0 END) as unit_mismatch_rows
|
||||
')->first();
|
||||
|
||||
@ -2018,8 +2302,8 @@ class Pages extends Controller
|
||||
'missing_price_rows' => (int) ($agg->missing_price_rows ?? 0),
|
||||
'missing_price_quantity' => round((float) ($agg->missing_price_quantity ?? 0), 2),
|
||||
'missing_warehouse_rows' => (int) ($agg->missing_warehouse_rows ?? 0),
|
||||
'excluded_rows' => $excludedRows,
|
||||
'fallback_price_rows' => (int) ($agg->fallback_price_rows ?? 0),
|
||||
'catalog_price_rows' => (int) ($agg->catalog_price_rows ?? 0),
|
||||
'unit_mismatch_rows' => (int) ($agg->unit_mismatch_rows ?? 0),
|
||||
],
|
||||
'productCostMonths' => $months,
|
||||
@ -2157,6 +2441,7 @@ class Pages extends Controller
|
||||
'financial-analysis-months-v1',
|
||||
now()->addMinutes(10),
|
||||
static fn () => DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
|
||||
->distinct()->orderByDesc('report_month')->pluck('report_month')
|
||||
->map(static fn ($value): string => substr((string) $value, 0, 7))->all(),
|
||||
);
|
||||
@ -2183,6 +2468,7 @@ class Pages extends Controller
|
||||
$rows = [];
|
||||
$incomeRows = DB::connection($connection)->table('store_receipt_order_match_rows')
|
||||
->where('report_month', $reportMonth)
|
||||
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
|
||||
->selectRaw('platform, store_name, SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
|
||||
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)) as income')
|
||||
->groupBy('platform', 'store_name')
|
||||
@ -2297,15 +2583,7 @@ class Pages extends Controller
|
||||
if ($warehouse !== '') {
|
||||
$query->where('warehouse_short_name', $warehouse);
|
||||
}
|
||||
if ($request->boolean('missing_price')) {
|
||||
$query->whereNull('unit_price');
|
||||
}
|
||||
if (! $request->boolean('include_excluded')) {
|
||||
$query->where(static function ($inner): void {
|
||||
$inner->whereNull('warehouse_short_name')
|
||||
->orWhere('warehouse_short_name', 'not like', '%-不看%');
|
||||
});
|
||||
}
|
||||
// 导出与页面一致:全部数据(含未匹配单价与「-不看」仓)。
|
||||
|
||||
$filename = '商品成本_' . ($month === null ? 'all' : substr($month, 0, 7)) . '.csv';
|
||||
|
||||
@ -2691,7 +2969,8 @@ class Pages extends Controller
|
||||
}));
|
||||
}
|
||||
|
||||
$perPage = 50;
|
||||
// 货款/退货明细较宽,固定每页 15 行,减少纵向滚动并保持横向核对区稳定。
|
||||
$perPage = in_array($dataset, ['payment-reconciliation', 'return-reconciliation'], true) ? 15 : 50;
|
||||
$currentPage = max((int) request('page', 1), 1);
|
||||
|
||||
return [
|
||||
@ -4791,7 +5070,13 @@ class Pages extends Controller
|
||||
$amountByRow[(int) ($pair['row_number'] ?? 0)] = $pair['amount'] ?? null;
|
||||
}
|
||||
|
||||
$results = app(CourierSurchargeSettlementMirrorRepository::class)->decideImportRows($pairs);
|
||||
$verificationKey = 'courier-surcharge:verify:' . company_id() . ':' . auth()->id() . ':'
|
||||
. hash('sha256', json_encode($pairs, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
$results = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock($verificationKey, 300)
|
||||
->get(fn () => app(CourierSurchargeSettlementMirrorRepository::class)->decideImportRows($pairs));
|
||||
if ($results === false) {
|
||||
return back()->with('error', '同一份数据正在核验,请等待本次核验完成后再提交。');
|
||||
}
|
||||
|
||||
return response()->streamDownload(function () use ($results, $amountByRow): void {
|
||||
$writer = new \App\Support\StreamingXlsxWriter('导入核验');
|
||||
@ -4858,9 +5143,9 @@ class Pages extends Controller
|
||||
return response()->streamDownload(function (): void {
|
||||
$writer = new \App\Support\StreamingXlsxWriter('导入模板');
|
||||
|
||||
$writer->appendRow(['单号', '附加费类型', '快递名称', '金额(可选)']);
|
||||
$writer->appendRow(['DPK317138931411', '退回费', '曹县德邦小件', 20]);
|
||||
$writer->appendRow(['DPK379220912936', '超长费', '常州德邦', null]);
|
||||
$writer->appendRow(['单号', '附加费类型', '金额(可选)']);
|
||||
$writer->appendRow(['DPK317138931411', '退回费', 20]);
|
||||
$writer->appendRow(['DPK379220912936', '超长费', null]);
|
||||
|
||||
$output = fopen('php://output', 'wb');
|
||||
|
||||
|
||||
@ -32,6 +32,15 @@ class PlantingWorkOrders extends Controller
|
||||
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(),
|
||||
|
||||
@ -6,6 +6,7 @@ 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;
|
||||
@ -29,6 +30,7 @@ class SupplierReconciliation extends Controller
|
||||
private readonly SupplierReconciliationPageData $pageData,
|
||||
private readonly SupplierReturnCsvExporter $returnCsvExporter,
|
||||
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
|
||||
private readonly SupplierComparisonCsvExporter $comparisonCsvExporter,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
@ -105,6 +107,23 @@ class SupplierReconciliation extends Controller
|
||||
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([
|
||||
@ -131,9 +150,10 @@ class SupplierReconciliation extends Controller
|
||||
'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'],
|
||||
'unit_price' => ['nullable', 'numeric'],
|
||||
'selected_rows' => ['nullable', 'array', 'max:600'],
|
||||
'selected_rows.*' => ['string', 'max:2048'],
|
||||
]);
|
||||
@ -145,14 +165,16 @@ class SupplierReconciliation extends Controller
|
||||
));
|
||||
|
||||
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'));
|
||||
$unitPrice = (float) $request->input('unit_price');
|
||||
$differences = array_values(array_filter(
|
||||
$differences,
|
||||
static fn (array $row): bool => (string) ($row['barcode'] ?? '') === $barcode
|
||||
static fn (array $row): bool => (string) ($row['purchaseNo'] ?? '') === $purchaseNo
|
||||
&& (string) ($row['date'] ?? '') === $date
|
||||
&& (string) ($row['barcode'] ?? '') === $barcode
|
||||
&& (string) ($row['type'] ?? '') === $type
|
||||
&& abs((float) ($row['unitPrice'] ?? 0) - $unitPrice) < 0.005,
|
||||
));
|
||||
} elseif ($request->input('scope') === 'selected') {
|
||||
$selected = [];
|
||||
@ -214,9 +236,10 @@ class SupplierReconciliation extends Controller
|
||||
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'] ?? '')),
|
||||
number_format((float) ($row['unitPrice'] ?? 0), 4, '.', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ class CourierQuoteVersion extends Model
|
||||
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
|
||||
'carrier_name', 'courier_expcode', 'billing_rules', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json',
|
||||
'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by',
|
||||
'billing_structure', 'billing_rows',
|
||||
];
|
||||
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
@ -26,6 +27,8 @@ class CourierQuoteVersion extends Model
|
||||
'effective_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'voided_at' => 'datetime',
|
||||
'billing_structure' => 'array',
|
||||
'billing_rows' => 'array',
|
||||
];
|
||||
|
||||
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
|
||||
@ -56,6 +59,14 @@ class CourierQuoteVersion extends Model
|
||||
: $this->effective_date->toDateString() . ' 起';
|
||||
}
|
||||
|
||||
/** 是否已有可用于运费核算的结构化计费规则。 */
|
||||
public function hasBillingRules(): bool
|
||||
{
|
||||
$mode = $this->billing_structure['mode'] ?? null;
|
||||
|
||||
return is_string($mode) && $mode !== 'other' && is_array($this->billing_rows) && $this->billing_rows !== [];
|
||||
}
|
||||
|
||||
public function isVoided(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_VOIDED;
|
||||
|
||||
157
app/Support/CourierQuoteFreightCalculator.php
Normal file
157
app/Support/CourierQuoteFreightCalculator.php
Normal file
@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 按「快递编码 + 发货日期」找到当天生效的报价版本,用结构化计费规则算一票的运费。
|
||||
*
|
||||
* 计费重量 = max(实重, 体积重, 起算重量) 再按取整方式处理;超过限重不计算。
|
||||
*
|
||||
* 生效版本的判断与报价管理页一致(CourierRequoteService::resolveAt:未作废、已审核或已签署归档、
|
||||
* 执行期间覆盖发货日期,短期变价优先)。没有结构化规则的版本(按体积、按条码等特殊计费)返回原因,交人工核算。
|
||||
*/
|
||||
class CourierQuoteFreightCalculator
|
||||
{
|
||||
/** @var array<string,Collection<int,CourierQuoteVersion>> */
|
||||
private array $versions = [];
|
||||
|
||||
/**
|
||||
* @param string $courierExpcode 快递编码;没有编码的存量报价可传快递名称(如「沐野竹木顺丰」)
|
||||
* @return array{
|
||||
* version_code:?string,company_name:?string,region:?string,gross_weight:float,volume_weight:?float,
|
||||
* billable_weight:?float,base_amount:?float,surcharge:float,amount:?float,formula:?string,reason:?string
|
||||
* }
|
||||
*/
|
||||
public function calculate(
|
||||
string $courierExpcode,
|
||||
DateTimeInterface $shipDate,
|
||||
string $province,
|
||||
float $grossWeight,
|
||||
?float $length = null,
|
||||
?float $width = null,
|
||||
?float $height = null,
|
||||
?string $city = null,
|
||||
?string $quoteType = null,
|
||||
): array {
|
||||
$result = [
|
||||
'version_code' => null, 'company_name' => null, 'region' => null, 'gross_weight' => $grossWeight,
|
||||
'volume_weight' => null, 'billable_weight' => null, 'base_amount' => null, 'surcharge' => 0.0,
|
||||
'amount' => null, 'formula' => null, 'reason' => null,
|
||||
];
|
||||
if (! is_finite($grossWeight) || $grossWeight < 0
|
||||
|| collect([$length, $width, $height])->contains(static fn ($v) => $v !== null && (! is_finite($v) || $v < 0))) {
|
||||
return ['reason' => '重量和尺寸必须是有效非负数字'] + $result;
|
||||
}
|
||||
|
||||
$version = $this->versionAt(trim($courierExpcode), $shipDate, $quoteType);
|
||||
if ($version === null) {
|
||||
return ['reason' => '发货日期没有生效的报价'] + $result;
|
||||
}
|
||||
$result['version_code'] = $version->version_code;
|
||||
$result['company_name'] = $version->company_name;
|
||||
if (! $version->hasBillingRules()) {
|
||||
return ['reason' => '该报价为特殊计费或尚未设置计费规则,需人工核算'] + $result;
|
||||
}
|
||||
|
||||
$structure = (array) $version->billing_structure;
|
||||
$row = $this->matchRow((array) $version->billing_rows, $province, $city);
|
||||
if ($row === null) {
|
||||
return ['reason' => '报价未覆盖目的地「' . trim($province . ' ' . $city) . '」或该地区不发'] + $result;
|
||||
}
|
||||
$result['region'] = (string) $row['region'];
|
||||
|
||||
$divisor = (float) ($structure['volumetric_divisor'] ?? 0);
|
||||
$volume = $divisor > 0 && $length > 0 && $width > 0 && $height > 0 ? round($length * $width * $height / $divisor, 4) : null;
|
||||
$settlement = max($grossWeight, (float) $volume);
|
||||
$result['volume_weight'] = $volume;
|
||||
if ($settlement <= 0) {
|
||||
return ['reason' => '缺少重量,无法计算运费'] + $result;
|
||||
}
|
||||
|
||||
$limit = (float) ($structure['weight_limit'] ?? 0);
|
||||
if ($limit > 0 && $settlement > $limit + 1e-9) {
|
||||
return ['reason' => '超出限重 ' . CourierQuoteStructure::kg($limit) . 'kg'] + $result;
|
||||
}
|
||||
|
||||
// 起算重量:不足按起算重量计(如菏泽顺帆顺丰不足 5kg 按 5kg)
|
||||
$minimum = (float) ($structure['min_weight'] ?? 0);
|
||||
$billable = CourierQuoteStructure::roundWeight(max($settlement, $minimum), (string) ($structure['rounding'] ?? 'none'));
|
||||
$result['billable_weight'] = $billable;
|
||||
$freight = CourierQuoteStructure::freight($structure, $row, $billable);
|
||||
if ($freight['amount'] === null) {
|
||||
return ['reason' => $freight['reason']] + $result;
|
||||
}
|
||||
|
||||
$surcharge = (float) ($structure['per_ticket_surcharge'] ?? 0);
|
||||
$result['base_amount'] = round($freight['amount'], 2);
|
||||
$result['surcharge'] = $surcharge;
|
||||
$result['amount'] = round($freight['amount'] + $surcharge, 2);
|
||||
$result['formula'] = $freight['formula'] . ($surcharge > 0 ? '+每票' . CourierQuoteStructure::kg($surcharge) : '');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** 省份名统一:去掉省/市/自治区后缀,黑龙→黑龙江、内蒙→内蒙古。 */
|
||||
public static function regionKey(string $name): string
|
||||
{
|
||||
$text = (string) preg_replace('/\s+/u', '', $name);
|
||||
$text = (string) preg_replace('/(维吾尔自治区|壮族自治区|回族自治区|自治区|特别行政区|省|市)$/u', '', $text);
|
||||
|
||||
return match ($text) {
|
||||
'黑龙' => '黑龙江',
|
||||
'内蒙' => '内蒙古',
|
||||
default => $text,
|
||||
};
|
||||
}
|
||||
|
||||
private function versionAt(string $courierExpcode, DateTimeInterface $date, ?string $quoteType): ?CourierQuoteVersion
|
||||
{
|
||||
if ($courierExpcode === '') {
|
||||
return null;
|
||||
}
|
||||
// 先按快递编码;部分存量版本没有编码,按快递名称(与报价管理页的分组口径一致)
|
||||
$cacheKey = company_id() . ':' . $courierExpcode;
|
||||
$versions = $this->versions[$cacheKey] ??= CourierQuoteVersion::query()
|
||||
->with(['signature', 'change'])
|
||||
->where(static fn ($query) => $query->where('courier_expcode', $courierExpcode)
|
||||
->orWhere(static fn ($q) => $q->where(static fn ($e) => $e->whereNull('courier_expcode')->orWhere('courier_expcode', ''))
|
||||
->where('company_name', $courierExpcode)))
|
||||
->get();
|
||||
|
||||
return CourierRequoteService::resolveAt(
|
||||
$quoteType === null ? $versions : $versions->where('quote_type', $quoteType),
|
||||
$date,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 先按城市匹配单列的城市行(如「深圳(广东)」),再按省份匹配。
|
||||
*
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function matchRow(array $rows, string $province, ?string $city): ?array
|
||||
{
|
||||
$cityKey = self::regionKey((string) $city);
|
||||
if ($cityKey !== '') {
|
||||
foreach ($rows as $row) {
|
||||
if (is_array($row) && preg_match('/^(.+?)[((](.+?)[))]$/u', trim((string) ($row['region'] ?? '')), $parts)
|
||||
&& self::regionKey($parts[1]) === $cityKey && self::regionKey($parts[2]) === self::regionKey($province)) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
$key = self::regionKey($province);
|
||||
foreach ($rows as $row) {
|
||||
if (is_array($row) && self::regionKey((string) ($row['region'] ?? '')) === $key) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,13 @@ final class CourierQuoteStructure
|
||||
'round' => '四舍五入(如 4.4kg 按 4kg,4.5kg 按 5kg)',
|
||||
];
|
||||
|
||||
/**
|
||||
* 分段计费:不放进 MODES(入驻表单不提供),只用于存量报价导入。
|
||||
* 每个省份行为 {region, segments:[{lo, hi, type: flat|waybill|first_add, price|base, rate, fw}]},
|
||||
* 重量落在 (lo, hi](第一段含 0)就按该段计算;hi 为 null 表示不封顶。
|
||||
*/
|
||||
public const SEGMENTS = 'segments';
|
||||
|
||||
public const MAX_BANDS = 12;
|
||||
|
||||
public const MAX_TIERS = 6;
|
||||
@ -272,10 +279,120 @@ final class CourierQuoteStructure
|
||||
? "首重{$fw}kg价+(重量-{$fw})*续重"
|
||||
: '面单+重量*费率') . $tierNote,
|
||||
'other' => '特殊计费,见附件报价表',
|
||||
self::SEGMENTS => '分段计费:每个重量段单独设一口价、面单+费率或首重+续重(可表示累进计费)',
|
||||
default => '面单+重量*费率' . $tierNote,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按结构化报价计算一票的基础运费(不含计泡、限重、附加费,这些由调用方先处理)。
|
||||
*
|
||||
* 与 describe() 口径一致:费率分段按「总重量所在区间」取费率,区间上限含本数;
|
||||
* 一口价/费率为空表示该重量段不发。$weight 应为已取整的计费重量。
|
||||
*
|
||||
* @param array<string,mixed> $structure
|
||||
* @param array<string,mixed> $row
|
||||
* @return array{amount:?float,formula:?string,reason:?string}
|
||||
*/
|
||||
public static function freight(array $structure, array $row, float $weight): array
|
||||
{
|
||||
$mode = (string) ($structure['mode'] ?? 'waybill_rate');
|
||||
$none = static fn (string $reason): array => ['amount' => null, 'formula' => null, 'reason' => $reason];
|
||||
$ok = static fn (float $amount, string $formula): array => ['amount' => round($amount, 4), 'formula' => $formula, 'reason' => null];
|
||||
if (! is_finite($weight) || $weight <= 0) {
|
||||
return $none('缺少有效重量,无法计算运费');
|
||||
}
|
||||
$w = self::kg($weight);
|
||||
$within = static fn (float $value, float $edge): bool => $value <= $edge + 1e-9;
|
||||
|
||||
if ($mode === self::SEGMENTS) {
|
||||
foreach ((array) ($row['segments'] ?? []) as $segment) {
|
||||
$lo = (float) ($segment['lo'] ?? 0);
|
||||
$hi = isset($segment['hi']) ? (float) $segment['hi'] : INF;
|
||||
if (! (($weight > $lo + 1e-9 || $lo <= 0.0) && $within($weight, $hi))) {
|
||||
continue;
|
||||
}
|
||||
$type = (string) ($segment['type'] ?? '');
|
||||
if (! in_array($type, ['flat', 'waybill', 'first_add'], true)) {
|
||||
return $none('不支持的分段计费方式,需人工核算');
|
||||
}
|
||||
if ($type === 'flat') {
|
||||
return isset($segment['price']) ? $ok((float) $segment['price'], '一口价' . self::kg((float) $segment['price'])) : $none('该重量段不发');
|
||||
}
|
||||
if (! isset($segment['base'], $segment['rate'])) {
|
||||
return $none('该重量段不发');
|
||||
}
|
||||
$base = (float) $segment['base'];
|
||||
$rate = (float) $segment['rate'];
|
||||
if ($type === 'first_add') {
|
||||
$fw = (float) ($segment['fw'] ?? 0);
|
||||
|
||||
return $within($weight, $fw)
|
||||
? $ok($base, '首重' . self::kg($base))
|
||||
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
|
||||
}
|
||||
|
||||
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
|
||||
}
|
||||
|
||||
return $none('超出报价重量范围');
|
||||
}
|
||||
|
||||
if (! in_array($mode, ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then'], true)) {
|
||||
return $none('特殊计费,需人工核算');
|
||||
}
|
||||
|
||||
$bands = array_map('floatval', array_values((array) ($structure['flat_bands'] ?? [])));
|
||||
$flat = array_values((array) ($row['flat'] ?? []));
|
||||
if (in_array($mode, ['flat_bands', 'flat_then'], true) && $bands !== [] && ($mode === 'flat_bands' || $within($weight, end($bands)))) {
|
||||
foreach ($bands as $index => $edge) {
|
||||
if ($within($weight, $edge)) {
|
||||
$price = $flat[$index] ?? null;
|
||||
|
||||
return is_numeric($price) ? $ok((float) $price, '≤' . self::kg($edge) . 'kg 一口价' . self::kg((float) $price)) : $none('该重量段不发');
|
||||
}
|
||||
}
|
||||
|
||||
return $none('超出报价重量范围');
|
||||
}
|
||||
|
||||
$tiers = array_map('floatval', array_values((array) ($structure['rate_tiers'] ?? [])));
|
||||
$index = count($tiers);
|
||||
foreach ($tiers as $i => $edge) {
|
||||
if ($within($weight, $edge)) {
|
||||
$index = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$rate = $row['rates'][$index] ?? null;
|
||||
$base = $row['base'] ?? null;
|
||||
if (! is_numeric($rate) || ! is_numeric($base)) {
|
||||
return $none('该重量段不发');
|
||||
}
|
||||
$rate = (float) $rate;
|
||||
$base = (float) $base;
|
||||
$then = $mode === 'flat_then' ? (string) ($structure['then_mode'] ?? 'waybill_rate') : $mode;
|
||||
if ($then === 'first_additional') {
|
||||
$fw = (float) ($structure['first_weight'] ?? 0);
|
||||
|
||||
return $within($weight, $fw)
|
||||
? $ok($base, '首重' . self::kg($base))
|
||||
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
|
||||
}
|
||||
|
||||
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
|
||||
}
|
||||
|
||||
/** 按取整方式得到计费重量:ceil 向上取整,round 四舍五入(4.5 进为 5)。 */
|
||||
public static function roundWeight(float $weight, string $rounding): float
|
||||
{
|
||||
return match ($rounding) {
|
||||
'ceil' => (float) ceil(round($weight, 6)),
|
||||
'round' => (float) floor($weight + 0.5),
|
||||
default => $weight,
|
||||
};
|
||||
}
|
||||
|
||||
public static function usesRates(string $mode): bool
|
||||
{
|
||||
return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true);
|
||||
|
||||
@ -142,6 +142,23 @@ final class QuoteManagementSnapshot
|
||||
return $companyName !== '' ? $companyName : $name;
|
||||
}
|
||||
|
||||
/** 「查看」面板的计费规则一行:计费方式 + 取整/计泡/限重/每票加收。 */
|
||||
private function billingSummary(CourierQuoteVersion $version): string
|
||||
{
|
||||
$structure = (array) $version->billing_structure;
|
||||
$kg = static fn ($value): string => CourierQuoteStructure::kg((float) $value);
|
||||
$parts = array_filter([
|
||||
CourierQuoteStructure::describe($structure),
|
||||
($structure['rounding'] ?? 'none') !== 'none' ? (CourierQuoteStructure::ROUNDING[$structure['rounding']] ?? null) : null,
|
||||
filled($structure['min_weight'] ?? null) ? '不足' . $kg($structure['min_weight']) . 'kg按' . $kg($structure['min_weight']) . 'kg计' : null,
|
||||
filled($structure['volumetric_divisor'] ?? null) ? '计泡 ' . $structure['volumetric_divisor'] : null,
|
||||
filled($structure['weight_limit'] ?? null) ? '限重 ' . $kg($structure['weight_limit']) . 'kg' : null,
|
||||
filled($structure['per_ticket_surcharge'] ?? null) ? '每票加收 ' . $kg($structure['per_ticket_surcharge']) . ' 元' : null,
|
||||
]);
|
||||
|
||||
return implode(';', $parts) . '(' . count((array) $version->billing_rows) . ' 个地区)';
|
||||
}
|
||||
|
||||
private function normalizeVersion(CourierQuoteVersion $version): array
|
||||
{
|
||||
$values = $this->decodeMatrix($version->values_json);
|
||||
@ -155,6 +172,7 @@ final class QuoteManagementSnapshot
|
||||
'company_name' => $version->company_name,
|
||||
'courier_expcode' => $version->courier_expcode,
|
||||
'billing_rules' => $version->billing_rules,
|
||||
'billing_summary' => $version->hasBillingRules() ? $this->billingSummary($version) : null,
|
||||
'carrier' => $version->carrier_name,
|
||||
'quote_type' => $version->quote_type,
|
||||
'effective_date' => $version->effective_date?->toDateString(),
|
||||
|
||||
@ -834,6 +834,8 @@ $navigation = [
|
||||
['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'],
|
||||
['name' => 'refund_amount', 'label' => '退款金额', 'kind' => 'number'],
|
||||
['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'],
|
||||
['name' => 'planting_reimbursement_amount', 'label' => '种菜报销', 'kind' => 'number'],
|
||||
['name' => 'planting_unreimbursed_amount', 'label' => '种菜未报销', 'kind' => 'number'],
|
||||
['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'],
|
||||
['name' => 'paid_amount', 'label' => '订单实付/实收金额', 'kind' => 'number'],
|
||||
['name' => 'difference', 'label' => '差额', 'kind' => 'number'],
|
||||
|
||||
@ -193,6 +193,13 @@ return [
|
||||
*/
|
||||
'table_sync' => [
|
||||
'batch_size' => (int) env('BUSINESS_MIRROR_TABLE_BATCH_SIZE', 500),
|
||||
// These wide SQL Server views time out when TOP/pagination is planned
|
||||
// together with every payload column. Select the bounded keys first,
|
||||
// then look up only those rows in the same read-only statement.
|
||||
'key_first_datasets' => [
|
||||
'operations_replica.rpt.jst_Orders',
|
||||
'operations_replica.rpt.jst_originalBillInfo',
|
||||
],
|
||||
'max_batches_per_run' => (int) env('BUSINESS_MIRROR_TABLE_MAX_BATCHES', 2),
|
||||
'max_datasets_per_run' => (int) env('BUSINESS_MIRROR_MAX_DATASETS', 1),
|
||||
'overlap_hours' => (int) env('BUSINESS_MIRROR_OVERLAP_HOURS', 48),
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 报价版本增加结构化计费规则,供运费核算使用;原始单元格(values_json)保留不动。
|
||||
*
|
||||
* billing_structure:{mode, flat_bands, then_mode, first_weight, rate_tiers, rounding, volumetric_divisor,
|
||||
* weight_limit, min_weight(起算重量), per_ticket_surcharge, notes}
|
||||
* billing_rows: [{region, flat, base, rates}] 或分段计费 [{region, segments}]
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('business_courier_quote_versions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('business_courier_quote_versions', 'billing_structure')) {
|
||||
$table->longText('billing_structure')->nullable();
|
||||
}
|
||||
if (! Schema::hasColumn('business_courier_quote_versions', 'billing_rows')) {
|
||||
$table->longText('billing_rows')->nullable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('business_courier_quote_versions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
|
||||
foreach (['billing_structure', 'billing_rows'] as $column) {
|
||||
if (Schema::hasColumn('business_courier_quote_versions', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if (! $schema->hasTable('work_order_waybill_lookup')) {
|
||||
$schema->create('work_order_waybill_lookup', function (Blueprint $table): void {
|
||||
$table->char('mirror_key', 64)->primary();
|
||||
$table->string('waybill', 191);
|
||||
$table->unsignedBigInteger('source_id');
|
||||
$table->index(['waybill', 'source_id'], 'work_order_waybill_source_idx');
|
||||
});
|
||||
}
|
||||
if (! $schema->hasTable('work_order_waybill_lookup_states')) {
|
||||
$schema->create('work_order_waybill_lookup_states', function (Blueprint $table): void {
|
||||
$table->unsignedTinyInteger('id')->primary();
|
||||
$table->char('cursor', 64)->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamp('updated_at')->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$schema->dropIfExists('work_order_waybill_lookup_states');
|
||||
$schema->dropIfExists('work_order_waybill_lookup');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if (! $schema->hasTable('store_report_paid_sales_dirty_dates')) {
|
||||
$schema->create('store_report_paid_sales_dirty_dates', function (Blueprint $table): void {
|
||||
$table->date('occurred_on')->primary();
|
||||
$table->uuid('revision');
|
||||
});
|
||||
}
|
||||
if ($schema->hasTable('store_receipt_order_match_rows')) {
|
||||
$indexed = collect($schema->getIndexes('store_receipt_order_match_rows'))
|
||||
->contains(fn (array $index) => ($index['columns'][0] ?? null) === 'occurred_date');
|
||||
if (! $indexed) {
|
||||
$connection = $schema->getConnection();
|
||||
if ($connection->getDriverName() === 'mysql') {
|
||||
// Fail quickly on metadata contention and require an online index build.
|
||||
$wait = (int) $connection->selectOne('SELECT @@SESSION.lock_wait_timeout AS seconds')->seconds;
|
||||
$connection->statement('SET SESSION lock_wait_timeout=5');
|
||||
try {
|
||||
$table = $connection->getQueryGrammar()->wrapTable('store_receipt_order_match_rows');
|
||||
$connection->statement('ALTER TABLE ' . $table . ' ADD INDEX store_receipt_paid_sales_date_index (occurred_date), ALGORITHM=INPLACE, LOCK=NONE');
|
||||
} finally {
|
||||
$connection->statement('SET SESSION lock_wait_timeout=' . $wait);
|
||||
}
|
||||
} else {
|
||||
$schema->table('store_receipt_order_match_rows', fn (Blueprint $table) => $table->index('occurred_date', 'store_receipt_paid_sales_date_index'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$schema->dropIfExists('store_report_paid_sales_dirty_dates');
|
||||
if ($schema->hasTable('store_receipt_order_match_rows') && collect($schema->getIndexes('store_receipt_order_match_rows'))->contains('name', 'store_receipt_paid_sales_date_index')) {
|
||||
$schema->table('store_receipt_order_match_rows', fn (Blueprint $table) => $table->dropIndex('store_receipt_paid_sales_date_index'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if (! $schema->hasTable('jd_daily_bill_rows')) return;
|
||||
if (collect($schema->getIndexes('jd_daily_bill_rows'))->contains('name', 'jd_daily_summary_cover')) return;
|
||||
$columns = ['generation', 'occurred_at', 'income', 'expense', 'store_name', 'bill_item'];
|
||||
$connection = $schema->getConnection();
|
||||
if ($connection->getDriverName() === 'mysql') {
|
||||
// Platform scope reads store_name: without it SUM must fetch every wide JSON row.
|
||||
$wait = (int) $connection->selectOne('SELECT @@SESSION.lock_wait_timeout AS seconds')->seconds;
|
||||
$connection->statement('SET SESSION lock_wait_timeout=5');
|
||||
try {
|
||||
$table = $connection->getQueryGrammar()->wrapTable('jd_daily_bill_rows');
|
||||
$connection->statement('ALTER TABLE ' . $table . ' ADD INDEX jd_daily_summary_cover (generation, occurred_at, income, expense, store_name, bill_item), ALGORITHM=INPLACE, LOCK=NONE');
|
||||
} finally {
|
||||
$connection->statement('SET SESSION lock_wait_timeout=' . $wait);
|
||||
}
|
||||
} else {
|
||||
$schema->table('jd_daily_bill_rows', fn (Blueprint $table) => $table->index($columns, 'jd_daily_summary_cover'));
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
|
||||
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
if ($schema->hasTable('jd_daily_bill_rows') && collect($schema->getIndexes('jd_daily_bill_rows'))->contains('name', 'jd_daily_summary_cover')) {
|
||||
$schema->table('jd_daily_bill_rows', fn (Blueprint $table) => $table->dropIndex('jd_daily_summary_cover'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 业绩明细新增「费用项」列:
|
||||
* 按科目映射表的 fee_memo(费用备注)归集到订单,一单多费用时用顿号连接。
|
||||
* 只用于展示,不参与收入、优惠、冲减、退款和差额计算。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->string('fee_item', 191)->nullable()->after('planting_amount');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')
|
||||
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->dropColumn('fee_item');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 京东网银钱包账户不再进入业绩明细:清理历史镜像行,收、支均不展示。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::connection($this->connection())->hasTable('store_receipt_order_match_rows')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::connection($this->connection())
|
||||
->table('store_receipt_order_match_rows')
|
||||
->where('platform', '京东网银钱包')
|
||||
->delete();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 历史镜像可由同步命令重建;本迁移只做排除清理,不回填已删除的数据。
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 撤销「费用项」功能:删除业绩明细镜像中的 fee_item 列。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')
|
||||
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->dropColumn('fee_item');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')
|
||||
|| $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->string('fee_item', 191)->nullable()->after('planting_amount');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 业绩明细新增「种菜报销」列(放在种菜金额后面):
|
||||
* 仅按「种菜明细表」的订单号命中,金额取该订单的收入金额。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $schema->hasColumn('store_receipt_order_match_rows', 'planting_reimbursement_amount')) {
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->decimal('planting_reimbursement_amount', 20, 4)
|
||||
->nullable()
|
||||
->after('planting_amount');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')
|
||||
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'planting_reimbursement_amount')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->dropColumn('planting_reimbursement_amount');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 业绩明细新增「种菜未报销」列(放在种菜报销后面):
|
||||
* 按聚水潭订单标签明细「是否种菜订单=是」且未出现在种菜明细表的订单号命中,金额取收入金额。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $schema->hasColumn('store_receipt_order_match_rows', 'planting_unreimbursed_amount')) {
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->decimal('planting_unreimbursed_amount', 20, 4)
|
||||
->nullable()
|
||||
->after('planting_reimbursement_amount');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('store_receipt_order_match_rows')
|
||||
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'planting_unreimbursed_amount')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->dropColumn('planting_unreimbursed_amount');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (app()->environment('testing') || config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
// 生产可能保留旧 SKU 级快照;使用独立源行基线,不修改或删除旧表。
|
||||
if (! $schema->hasTable('jushuitan_cost_price_observation_baselines')) {
|
||||
$schema->create('jushuitan_cost_price_observation_baselines', function (Blueprint $table): void {
|
||||
$table->char('source_key', 64)->primary();
|
||||
$table->string('sku_id', 255);
|
||||
$table->decimal('cost_price', 20, 4);
|
||||
$table->dateTime('captured_at', 6);
|
||||
});
|
||||
}
|
||||
if (! $schema->hasTable('jushuitan_cost_price_observations')) {
|
||||
$schema->create('jushuitan_cost_price_observations', function (Blueprint $table): void {
|
||||
$table->char('id', 64)->primary();
|
||||
$table->char('source_key', 64);
|
||||
$table->string('sku_id', 255);
|
||||
$table->decimal('before_price', 20, 4);
|
||||
$table->decimal('after_price', 20, 4);
|
||||
$table->dateTime('previous_observed_at', 6);
|
||||
$table->dateTime('observed_at', 6);
|
||||
$table->dateTime('source_modified_at', 6)->nullable();
|
||||
$table->index(['source_key', 'observed_at', 'id'], 'jst_cost_observations_lookup');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (app()->environment('testing') || config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$schema->dropIfExists('jushuitan_cost_price_observations');
|
||||
$schema->dropIfExists('jushuitan_cost_price_observation_baselines');
|
||||
}
|
||||
};
|
||||
@ -7,6 +7,11 @@ REMOTE_DIR="${3:-/opt/caiwuxitong}"
|
||||
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
|
||||
RUN_MIGRATIONS="${5:-0}"
|
||||
KEEP_IMAGES="${6:-2}"
|
||||
EXPECTED_PREVIOUS_SHA="${7:?Checked production revision is required; update the deployment scripts}"
|
||||
DEPLOY_ACTOR="${8:-unknown}"
|
||||
RELEASE_APP_URL="${9:-}"
|
||||
RELEASE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$RELEASE_DIR/release-guard.sh"
|
||||
IMAGE_NAME="caiwu_akaunting"
|
||||
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
|
||||
REGISTRY_API="http://127.0.0.1:5000/v2/caiwuxitong/app"
|
||||
@ -65,6 +70,12 @@ if ! flock -n 9; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
RELEASE_RESULT=blocked
|
||||
trap 'release_status=$?; caiwu_record_release_result "$release_status"' EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。
|
||||
exec 8>/run/caiwu-upstream-sync.lock
|
||||
echo "Waiting for active upstream synchronization before replacing the application container"
|
||||
@ -74,7 +85,8 @@ if ! flock -w 1800 8; then
|
||||
fi
|
||||
|
||||
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
|
||||
caiwu_verify_release_base
|
||||
RELEASE_RESULT=running
|
||||
|
||||
restore_previous_image() {
|
||||
case "$PREVIOUS_IMAGE" in
|
||||
@ -96,6 +108,8 @@ if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
caiwu_install_release_files
|
||||
|
||||
cd "$REMOTE_DIR"
|
||||
export IMAGE_TAG RUN_MIGRATIONS
|
||||
|
||||
@ -196,6 +210,8 @@ CRON_TZ=Asia/Shanghai
|
||||
40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
|
||||
# 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。
|
||||
3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1
|
||||
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
|
||||
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
|
||||
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1
|
||||
@ -209,6 +225,7 @@ CRON_TZ=Asia/Shanghai
|
||||
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --quiet >/dev/null 2>&1
|
||||
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
|
||||
* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1
|
||||
* * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1
|
||||
28 1 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh jushuitan:sync-shipping --days=3 >/dev/null 2>&1
|
||||
# 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用
|
||||
# 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。
|
||||
|
||||
@ -7,6 +7,11 @@ REMOTE_DIR="${3:-/opt/caiwuxitong}"
|
||||
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
|
||||
RUN_MIGRATIONS="${5:-0}"
|
||||
KEEP_IMAGES="${6:-2}"
|
||||
EXPECTED_PREVIOUS_SHA="${7:?Checked production revision is required; update the deployment scripts}"
|
||||
DEPLOY_ACTOR="${8:-unknown}"
|
||||
RELEASE_APP_URL="${9:-}"
|
||||
RELEASE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
. "$RELEASE_DIR/release-guard.sh"
|
||||
IMAGE_NAME="caiwu_akaunting"
|
||||
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
|
||||
|
||||
@ -14,7 +19,9 @@ cleanup() {
|
||||
rm -f "$IMAGE_ARCHIVE"
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "activate-release.sh must run as root" >&2
|
||||
@ -69,6 +76,10 @@ if ! flock -n 9; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
RELEASE_RESULT=blocked
|
||||
trap 'release_status=$?; caiwu_record_release_result "$release_status"; cleanup' EXIT
|
||||
|
||||
# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。
|
||||
exec 8>/run/caiwu-upstream-sync.lock
|
||||
echo "Waiting for active upstream synchronization before replacing the application container"
|
||||
@ -78,7 +89,8 @@ if ! flock -w 1800 8; then
|
||||
fi
|
||||
|
||||
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
|
||||
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
|
||||
caiwu_verify_release_base
|
||||
RELEASE_RESULT=running
|
||||
|
||||
restore_previous_image() {
|
||||
case "$PREVIOUS_IMAGE" in
|
||||
@ -100,6 +112,8 @@ if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
caiwu_install_release_files
|
||||
|
||||
cd "$REMOTE_DIR"
|
||||
export IMAGE_TAG RUN_MIGRATIONS
|
||||
|
||||
@ -200,6 +214,8 @@ CRON_TZ=Asia/Shanghai
|
||||
40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
|
||||
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
|
||||
# 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。
|
||||
3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1
|
||||
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
|
||||
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
|
||||
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1
|
||||
@ -213,6 +229,7 @@ CRON_TZ=Asia/Shanghai
|
||||
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --quiet >/dev/null 2>&1
|
||||
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
|
||||
* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1
|
||||
* * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1
|
||||
28 1 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh jushuitan:sync-shipping --days=3 >/dev/null 2>&1
|
||||
# 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用
|
||||
# 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。
|
||||
|
||||
43
deploy/install-release-protection.sh
Normal file
43
deploy/install-release-protection.sh
Normal file
@ -0,0 +1,43 @@
|
||||
#!/bin/sh
|
||||
# Caller holds the production deployment lock. Does not restart any service.
|
||||
set -eu
|
||||
REMOTE_DIR="${1:-/opt/caiwuxitong}"
|
||||
SOURCE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
LEGACY_STUB="$SOURCE_DIR/retired-activation.sh"
|
||||
[ "$(id -u)" -eq 0 ] || { echo 'Release protection must be installed as root' >&2; exit 1; }
|
||||
[ -f "$LEGACY_STUB" ] || { echo 'Missing retired activation stub' >&2; exit 1; }
|
||||
|
||||
is_immutable() { lsattr -d "$1" 2>/dev/null | awk '{print $1}' | grep -q i; }
|
||||
needs_install=0
|
||||
for legacy in activate-release.sh activate-incremental-release.sh; do
|
||||
if cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"; then
|
||||
continue
|
||||
fi
|
||||
if is_immutable "$REMOTE_DIR/$legacy"; then
|
||||
echo "Unexpected immutable legacy entry point: $legacy; verify it before changing protection" >&2
|
||||
exit 1
|
||||
fi
|
||||
needs_install=1
|
||||
done
|
||||
[ "$needs_install" -eq 1 ] || exit 0
|
||||
|
||||
# Test support on an owned temporary file before touching either entry point.
|
||||
probe="$(mktemp "$REMOTE_DIR/.release-protection.XXXXXX")"
|
||||
cleanup_probe() { chattr -i "$probe" 2>/dev/null || true; rm -f "$probe"; }
|
||||
trap cleanup_probe EXIT
|
||||
chattr +i "$probe" || { echo 'Filesystem cannot protect legacy entry points; no application was changed' >&2; exit 1; }
|
||||
chattr -i "$probe"
|
||||
backup="$REMOTE_DIR/runtime/private/release-protection/$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||||
umask 077
|
||||
mkdir -p "$backup"
|
||||
for legacy in activate-release.sh activate-incremental-release.sh; do
|
||||
if cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"; then
|
||||
continue
|
||||
fi
|
||||
[ ! -f "$REMOTE_DIR/$legacy" ] || cp -p "$REMOTE_DIR/$legacy" "$backup/$legacy"
|
||||
install -m 700 "$LEGACY_STUB" "$REMOTE_DIR/.$legacy.next"
|
||||
mv "$REMOTE_DIR/.$legacy.next" "$REMOTE_DIR/$legacy"
|
||||
chattr +i "$REMOTE_DIR/$legacy"
|
||||
cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"
|
||||
done
|
||||
echo 'Legacy activation paths protected; current application and databases were not restarted'
|
||||
72
deploy/release-guard.sh
Normal file
72
deploy/release-guard.sh
Normal file
@ -0,0 +1,72 @@
|
||||
#!/bin/sh
|
||||
# Sourced by both activation paths after the deployment and synchronization locks.
|
||||
|
||||
caiwu_verify_release_base() {
|
||||
case "$EXPECTED_PREVIOUS_SHA" in
|
||||
none) ;;
|
||||
*[!0-9a-f]*|'') echo 'Missing or invalid checked production revision; update the deployment scripts' >&2; return 1 ;;
|
||||
*) [ "${#EXPECTED_PREVIOUS_SHA}" -eq 40 ] || return 1 ;;
|
||||
esac
|
||||
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
|
||||
[ -n "$PREVIOUS_SHA" ] || PREVIOUS_SHA=none
|
||||
if [ "$PREVIOUS_SHA" != none ] && ! printf '%s\n' "$PREVIOUS_SHA" | grep -Eq '^[0-9a-f]{40}$'; then
|
||||
echo 'Invalid production revision marker; refusing activation' >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$PREVIOUS_SHA" != "$EXPECTED_PREVIOUS_SHA" ]; then
|
||||
echo "Production changed while this release was being prepared: checked=$EXPECTED_PREVIOUS_SHA current=$PREVIOUS_SHA requested=$DEPLOY_SHA. Fetch latest main and retry; the running application was not changed." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# A failed/interrupted older activation must not leave the marker ahead of the app.
|
||||
running_sha="$(docker inspect caiwu_app --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null || true)"
|
||||
if [ "$PREVIOUS_SHA" != none ] && [ "$running_sha" != "$PREVIOUS_SHA" ]; then
|
||||
echo "Running image and production marker disagree; refusing activation: marker=$PREVIOUS_SHA image=$running_sha" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ "$PREVIOUS_SHA" = none ] && [ -n "$running_sha" ]; then
|
||||
echo 'An application is already running without a production marker; verify its revision before deploying' >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
caiwu_install_release_files() {
|
||||
# Each uploader has its own directory. Shared runtime files change only inside
|
||||
# the activation lock and after the production revision has been rechecked.
|
||||
[ "$RELEASE_DIR" != "$REMOTE_DIR" ] || return 0
|
||||
release_files='compose.jiangling.yml activate-release.sh activate-incremental-release.sh release-guard.sh retired-activation.sh install-release-protection.sh config-db-init.sh run-finance-batch.sh clickhouse-resource-policy.xml update-runtime-url.sh configure-dingtalk-callback.sh host-nginx-8442-dingtalk.conf setup-incremental-registry.sh registry-config.yml'
|
||||
for release_file in $release_files; do
|
||||
[ -f "$RELEASE_DIR/$release_file" ] || { echo "Incomplete staged release: $release_file" >&2; return 1; }
|
||||
done
|
||||
sh "$RELEASE_DIR/install-release-protection.sh" "$REMOTE_DIR" || return 1
|
||||
for release_file in $release_files; do
|
||||
case "$release_file" in activate-release.sh|activate-incremental-release.sh) continue ;; esac
|
||||
case "$release_file" in *.sh) release_mode=700 ;; *) release_mode=644 ;; esac
|
||||
[ "$release_file" != config-db-init.sh ] || release_mode=644
|
||||
install -m "$release_mode" "$RELEASE_DIR/$release_file" "$REMOTE_DIR/.$release_file.next"
|
||||
mv "$REMOTE_DIR/.$release_file.next" "$REMOTE_DIR/$release_file"
|
||||
done
|
||||
if [ -n "$RELEASE_APP_URL" ]; then
|
||||
"$REMOTE_DIR/update-runtime-url.sh" "$RELEASE_APP_URL" "$REMOTE_DIR"
|
||||
fi
|
||||
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.release-controls-sha.next"
|
||||
mv "$REMOTE_DIR/.release-controls-sha.next" "$REMOTE_DIR/.release-controls-sha"
|
||||
}
|
||||
|
||||
caiwu_record_release_result() {
|
||||
release_exit="$1"
|
||||
[ "$RELEASE_RESULT" != running ] || RELEASE_RESULT=failed
|
||||
[ "$release_exit" -ne 0 ] || RELEASE_RESULT=success
|
||||
# Client identity is declared by the workstation; the SSH source is recorded
|
||||
# separately. Neither field is inferred from the commit author.
|
||||
release_actor="$(printf '%s' "$DEPLOY_ACTOR" | tr -cd 'A-Za-z0-9_.@:-' | cut -c1-160)"
|
||||
release_peer="$(printf '%s' "${SSH_CONNECTION:-local}" | tr -cd 'A-Za-z0-9 .:-' | cut -c1-160)"
|
||||
(
|
||||
umask 077
|
||||
mkdir -p "$REMOTE_DIR/runtime/private"
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$RELEASE_STARTED_AT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$release_actor" "$release_peer" \
|
||||
"${PREVIOUS_SHA:-unknown}" "$DEPLOY_SHA" "$RELEASE_RESULT" "$release_exit" "$EXPECTED_PREVIOUS_SHA" \
|
||||
>> "$REMOTE_DIR/runtime/private/deployments.tsv"
|
||||
) || echo 'Could not write the private deployment receipt' >&2
|
||||
}
|
||||
4
deploy/retired-activation.sh
Normal file
4
deploy/retired-activation.sh
Normal file
@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
# This legacy shared path is intentionally retired and immutable on production.
|
||||
echo 'Legacy activation is disabled. Fetch latest main and use the workstation deployment script with an isolated release directory and checked production revision. The running application was not changed.' >&2
|
||||
exit 1
|
||||
@ -4,14 +4,35 @@ set -eu
|
||||
REMOTE_DIR="${CAIWU_REMOTE_DIR:-/opt/caiwuxitong}"
|
||||
LOCK_FILE="${CAIWU_UPSTREAM_LOCK:-/run/caiwu-upstream-sync.lock}"
|
||||
case "${1:-}" in
|
||||
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates) ;;
|
||||
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run) ;;
|
||||
*) echo 'Unsupported finance batch command' >&2; exit 64 ;;
|
||||
esac
|
||||
cd "$REMOTE_DIR"
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -w "${CAIWU_BATCH_LOCK_WAIT:-1800}" 9; then
|
||||
if [ "$1" = 'account-bills:exports:run' ]; then
|
||||
storage_root="$(docker inspect caiwu_app --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/storage"}}{{.Source}}{{end}}{{end}}')"
|
||||
[ -n "$storage_root" ] && [ -d "$storage_root" ] || exit 65
|
||||
# Check only tiny task metadata; do not start an idle container every minute.
|
||||
if ! python3 - "$storage_root" <<'PY'
|
||||
import json,pathlib,sys
|
||||
for path in pathlib.Path(sys.argv[1]).glob('app/account-bills-exports/*/*.json'):
|
||||
try:
|
||||
task = json.loads(path.read_text())
|
||||
if isinstance(task, dict) and task.get('status') == 'queued':
|
||||
sys.exit(0)
|
||||
except (OSError,ValueError):
|
||||
continue
|
||||
sys.exit(1)
|
||||
PY
|
||||
then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
if [ "$1" != 'account-bills:exports:run' ]; then
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -w "${CAIWU_BATCH_LOCK_WAIT:-1800}" 9; then
|
||||
echo 'Upstream synchronization is busy; no batch container started' >&2
|
||||
exit 75
|
||||
fi
|
||||
fi
|
||||
image="$(docker inspect caiwu_app --format '{{.Config.Image}}')"
|
||||
case "$image" in
|
||||
@ -20,5 +41,5 @@ case "$image" in
|
||||
esac
|
||||
RUN_MIGRATIONS=0
|
||||
export IMAGE_TAG RUN_MIGRATIONS
|
||||
# Holds the same lock as scheduled sync and release activation for the full job.
|
||||
# Mutating jobs hold the upstream lock; read-only exports use their own single-worker lock.
|
||||
docker compose -f compose.jiangling.yml --profile batch run --rm -T --no-deps batch "$@"
|
||||
|
||||
@ -35,6 +35,14 @@
|
||||
|
||||
抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。
|
||||
|
||||
### 成本价镜像观测
|
||||
|
||||
`business-mirror:sync-jushuitan-cost-price-history --source=snapshot` 仅比较既有 `ops_jst_sku` 的当前成本价,首次读取和新增商品只建立基线。基线写入 `jushuitan_cost_price_observation_baselines`,后续观测变化写入独立的 `jushuitan_cost_price_observations`;状态使用 `business_mirror_table_states` 中的 `jushuitan.product-cost-price-observations`,不共用正式操作历史的同步状态。生产保留的旧 SKU 级 `jushuitan_cost_price_snapshots` 不作覆盖、删除或追溯导入。
|
||||
|
||||
观测任务沿 `_mirror_synced_at + _mirror_key` 索引顺序分批续传,默认每批 500 条、每轮最多 20 批;固定窗口并保留两分钟重叠,基线、事件、游标同事务提交。不进行每轮全表计数。生产任务每十分钟尝试运行,沿用 `/run/caiwu-upstream-sync.lock`,繁忙时跳过。首次基线较大时可由同步节点持有同一互斥锁分批完成。
|
||||
|
||||
普通商品资料独立展示每个源商品最近 20 条镜像价格观测,时间明确表示“发现时间”。`modified` 只是商品更新时间,不能充当真实改价时间;采集间隔内的中间价格、建立基线前的历史无法从当前镜像还原。正式操作历史仍使用默认 `--source=open_api`;月度加权成本及其他财务计算继续只读原正式历史,不使用观测结果。
|
||||
|
||||
## 经营分析副本库(operations_replica)
|
||||
|
||||
店铺维度报表不使用订单件数代替销量。中央刷新节点按上游主键 `Id`
|
||||
|
||||
@ -129,11 +129,22 @@ bash scripts/deploy-from-mac.sh
|
||||
8. 验证唯一私有七牛桶的写入、读取和删除,并把旧本机业务上传幂等复制到七牛;不删除本机原件;
|
||||
9. 检查服务器本机后端页面;
|
||||
10. 任一存储验证或页面验证失败时恢复上一个镜像;
|
||||
11. 使用服务器互斥锁阻止两个人同时切换版本;
|
||||
11. 每次上传使用独立暂存目录;服务器取得发布锁和同步锁后,再核对线上 SHA 与发布机已检查的 SHA,确认未变化才切换配置和应用;
|
||||
12. 删除本地临时镜像包并关闭 SSH 隧道;
|
||||
13. 开发机、服务器 Docker 和服务器 Registry 均仅保留当前和上一个本项目版本;
|
||||
14. 清理 7 天前的本项目构建缓存,并将总量压到最多 3GB。
|
||||
|
||||
### 5.0 多人发布保护
|
||||
|
||||
- 远端 `main` 禁止直接推送和强推,任务分支通过 PR 合并;不额外要求人工审批或新增 CI 门槛。
|
||||
- 发布机先刷新远端 `main`,正式发布必须与最新主线一致,并检查本次版本包含线上提交。
|
||||
- 上传期间若别人已发布,服务器在锁内拒绝本次激活,运行中的应用保持原版本。先 `git fetch`、快进到最新 `main` 并重新发布,不用回滚开关绕过并发校验。
|
||||
- Windows 和 macOS 发布机都必须使用最新脚本。共享根目录的两个旧激活入口已停用并设置 Linux immutable 属性,旧发布机无法用上传覆盖它们;新脚本从每次独立暂存目录执行激活,缺少已检查线上版本参数也会被拒绝。
|
||||
- 运行配置按每次发布独立暂存,核对通过后才在锁内安装;上传文件不会提前覆盖其他发布使用的配置。
|
||||
- 服务器私有 `runtime/private/deployments.tsv` 记录起止 UTC 时间、发布机声明的身份、SSH 来源、前后 SHA、结果、退出码和已检查的 SHA。声明身份及提交作者不能单独证明具体操作者;记录不包含凭据或业务数据。
|
||||
- 安装旧入口保护前自动备份到服务器私有 `runtime/private/release-protection/`。普通发布不解除 immutable 属性;维护这些停用入口时先核对备份和目的,再由维护人员使用 `chattr -i` 处理指定文件。应用镜像回滚不需要启用旧入口。
|
||||
- 仅维护发布控制脚本时,可在发布锁内备份并安装已合并主线的控制脚本,记录独立的 `.release-controls-sha`,不重建应用容器、不修改表示应用镜像版本的 `.deployed-sha`。
|
||||
|
||||
### 5.1 安全软件拦截 regctl 时的降级
|
||||
|
||||
第 6 步默认用项目专用 `regctl`(未签名)把 BuildKit 原生 OCI 分层包推入服务器本机 Registry。少数开发机的 360 安全卫士、金山毒霸等安全软件会拦截未签名程序,表现为 `regctl-v0.11.5.exe` 启动即报“拒绝访问”。
|
||||
@ -278,6 +289,14 @@ sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:order-match:sync YYYY-MM
|
||||
|
||||
入口持有现有上游同步锁,使用 `batch` Profile 的独立容器(1 核、3GiB 内存、不使用 Swap、无 Web 健康检查);正常发布不启动该 Profile。先备份再执行回补,失败时保留原有未完成状态,不重复并行启动任务。店铺候选平时按镜像同步时间增量刷新,02:45 使用 `--full` 全量核对。ClickHouse 系统日志的保留期由 `deploy/clickhouse-resource-policy.xml` 管理;业务表不受日志策略影响。
|
||||
|
||||
查件运单索引 `work_order_waybill_lookup` 只保存原始工单镜像的稳定键、运单号与排序键;工单 JSON 仍为事实来源。升级后先通过上述批入口执行 `business-mirror:index-work-order-waybills --max-batches=0 --pause-ms=50` 完成一次可续传初始化,完成前保留现有核验路径。其后索引与原始工单批次在同一事务中更新,全量校准同时移除失效关联。不得每次导入重新扫描工单 JSON。
|
||||
|
||||
销量汇总按 `store_report_paid_sales_dirty_dates` 中的受影响日期更新。业绩明细写入与汇总共用互斥锁,改金额、替换账期和删除都必须登记旧日期及新日期。镜像人工校准后,可通过批入口执行 `business-mirror:refresh-paid-sales --full` 全量重建派生汇总;普通刷新无需全表指纹扫描。科目重算每轮默认处理一个平台月份,再释放同步锁,剩余任务由下一轮继续。
|
||||
|
||||
账单导出保存在原有文件任务队列,每分钟由 `account-bills:exports:run` 消费一个任务,任一时刻只有一个重型导出。该只读任务使用同一受限批 Profile 和独立导出锁,不长期持有上游写同步锁;空队列不启动容器。状态、取消、下载和七天文件保留沿用现有逻辑。
|
||||
|
||||
生产临时排查先使用元数据和 `EXPLAIN`,业务读取须设置合理的 SQL 执行时限,并使用已有索引缩小范围。不要在网页容器内对归档大表进行无时限计数、模糊查找或循环重试;需要全表核对时先安排受控批处理窗口。
|
||||
|
||||
### 8.3 开发者需要尚未接入的上游数据时
|
||||
|
||||
系统数据库保存团队配置、数据字典和同步状态,共享业务库保存已接入业务行;二者都不是 SQL Server 网络代理。开发探索与正式页面的读取方式不同:
|
||||
|
||||
@ -38,6 +38,9 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
/** 导入核验时最多回查多少张扣单工单,避免大表扫描过重。 */
|
||||
private const IMPORT_RETURN_CONTEXT_LIMIT = 100;
|
||||
|
||||
/** 导入核验时最多按单号回查多少张销售出库单(超长费)。 */
|
||||
private const IMPORT_OVERLENGTH_CONTEXT_LIMIT = 200;
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $database)
|
||||
@ -276,9 +279,26 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
}
|
||||
}
|
||||
|
||||
$contextComplete = count($unmatchedDeductWaybills) <= self::IMPORT_RETURN_CONTEXT_LIMIT;
|
||||
$contextComplete = count($unmatchedDeductWaybills) <= self::IMPORT_RETURN_CONTEXT_LIMIT
|
||||
|| app(\Modules\EcommerceFinance\Services\WorkOrderWaybillLookup::class)->ready();
|
||||
$returnContext = $this->deductOrderContext(array_keys($unmatchedDeductWaybills));
|
||||
$ruleModes = $this->activeRuleModes();
|
||||
$quoteRules = $this->importQuoteRules();
|
||||
|
||||
// 超长费没有结算行时,按单号回销售出库单查快递/尺寸并按启用的报价核算。
|
||||
$unmatchedOverlengthWaybills = [];
|
||||
|
||||
foreach ($normalized as $pair) {
|
||||
if ($pair['waybill'] === '' || $pair['surcharge_type'] !== '超长费') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->matchImportRow($rowsByWaybill[$pair['waybill']] ?? [], $pair['surcharge_type'], $pair['courier_name']) === null) {
|
||||
$unmatchedOverlengthWaybills[$pair['waybill']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$overlengthContext = $this->importOverlengthContext(array_keys($unmatchedOverlengthWaybills));
|
||||
|
||||
$fallbackRows = [];
|
||||
|
||||
@ -323,7 +343,16 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
|
||||
$results[] = $matched !== null
|
||||
? $this->importMatchedRow($pair, $matched)
|
||||
: $this->importMissingRow(
|
||||
: ($this->importQuoteResult(
|
||||
$pair,
|
||||
$fallbackRows[$pair['waybill']] ?? null,
|
||||
$returnContext,
|
||||
$ruleModes,
|
||||
$quoteRules,
|
||||
$overlengthContext,
|
||||
$contextWeights,
|
||||
$contextReturns,
|
||||
) ?? $this->importMissingRow(
|
||||
$pair,
|
||||
$returnContext,
|
||||
$ruleModes,
|
||||
@ -332,7 +361,7 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
$fallbackRows[$pair['waybill']] ?? null,
|
||||
$contextWeights,
|
||||
$contextReturns,
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
return $results;
|
||||
@ -422,7 +451,7 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
* @param array<string,array<string,mixed>> $returnContext
|
||||
* @param array<string,array<string,list<string>>> $ruleModes
|
||||
* @param array<string,float> $contextWeights
|
||||
* @param array<int,string> $contextReturns
|
||||
* @param array<int,list<array<string,mixed>>> $contextReturns
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function importMissingRow(
|
||||
@ -474,7 +503,9 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
$onlineOrderNo = trim((string) ($context['online_order_no'] ?? ''));
|
||||
$inspectionType = trim((string) ($context['inspection_type'] ?? '')) ?: null;
|
||||
$inspectionReason = trim((string) ($context['inspection_reason'] ?? ''));
|
||||
$returnWaybillNo = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? null;
|
||||
$orderReturns = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? [];
|
||||
$returnWaybills = array_values(array_unique(array_filter(array_column($orderReturns, 'l_id'))));
|
||||
$returnWaybillNo = $returnWaybills === [] ? null : implode(',', $returnWaybills);
|
||||
$settlementWeight = $this->importWeightSum($context['items'] ?? [], $contextWeights);
|
||||
$sendTime = trim((string) ($context['send_time'] ?? ''));
|
||||
$shippedAt = $sendTime !== '' ? $sendTime : null;
|
||||
@ -517,6 +548,591 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入核验的「按报价核算」:结算表里没有该运单+类型的结算行时,
|
||||
* 用单号回销售出库单(超长费)或查件扣单(退回费/拦截费)取上下文,
|
||||
* 再按启用的报价规则计算应结金额。返回 null 表示该单号取不到上下文,
|
||||
* 交给 importMissingRow 输出兜底的不结原因。
|
||||
*
|
||||
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
|
||||
* @param array<string,array<string,mixed>> $returnContext
|
||||
* @param array<string,array<string,list<string>>> $ruleModes
|
||||
* @param array{overlength:list<array<string,mixed>>,return:list<array<string,mixed>>,intercept:list<array<string,mixed>>} $quoteRules
|
||||
* @param array<string,array<string,mixed>> $overlengthContext
|
||||
* @param array<string,float> $contextWeights
|
||||
* @param array<int,list<array<string,mixed>>> $contextReturns
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function importQuoteResult(
|
||||
array $pair,
|
||||
?object $fallbackRow,
|
||||
array $returnContext,
|
||||
array $ruleModes,
|
||||
array $quoteRules,
|
||||
array $overlengthContext,
|
||||
array $contextWeights,
|
||||
array $contextReturns,
|
||||
): ?array {
|
||||
$type = $pair['surcharge_type'];
|
||||
|
||||
if ($type === '超长费') {
|
||||
$context = $overlengthContext[$pair['waybill']] ?? null;
|
||||
|
||||
return $context === null
|
||||
? null
|
||||
: $this->importOverlengthQuoteResult($pair, $context, $quoteRules['overlength'] ?? []);
|
||||
}
|
||||
|
||||
if (! in_array($type, ['退回费', '拦截费'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = $returnContext[$pair['waybill']] ?? null;
|
||||
|
||||
return $context === null
|
||||
? null
|
||||
: $this->importDeductQuoteResult($pair, $context, $fallbackRow, $ruleModes, $quoteRules, $contextWeights, $contextReturns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按单号回销售出库单(JianSun)取快递、明细、尺寸与平台商品ID。
|
||||
*
|
||||
* @param list<string> $waybills
|
||||
* @return array<string,array<string,mixed>>
|
||||
*/
|
||||
private function importOverlengthContext(array $waybills): array
|
||||
{
|
||||
if ($waybills === [] || count($waybills) > self::IMPORT_OVERLENGTH_CONTEXT_LIMIT) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$orders = [];
|
||||
$orderByWaybill = [];
|
||||
|
||||
foreach (array_chunk($waybills, 100) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
'SELECT Id, io_id, o_id, l_id, so_id, shop_name, '
|
||||
. "CONVERT(varchar(19), io_date, 120) AS io_date, logistics_company "
|
||||
. 'FROM dbo.jst_salesOutboundInfo WHERE l_id IN (' . $placeholders . ') ORDER BY Id DESC',
|
||||
$chunk,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$waybill = trim((string) ($row['l_id'] ?? ''));
|
||||
|
||||
if ($waybill === '' || isset($orderByWaybill[$waybill])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$orderByWaybill[$waybill] = $row;
|
||||
$orders[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
if ($orders === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ioIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $order): int => (int) ($order['io_id'] ?? 0),
|
||||
$orders,
|
||||
))));
|
||||
$items = [];
|
||||
$skuIds = [];
|
||||
|
||||
foreach (array_chunk($ioIds, 200) as $chunk) {
|
||||
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
'SELECT io_id, sku_id, i_id, qty FROM dbo.jst_salesOutboundItem WHERE io_id IN (' . $placeholders . ')',
|
||||
$chunk,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$ioId = (int) ($row['io_id'] ?? 0);
|
||||
$skuId = trim((string) ($row['sku_id'] ?? ''));
|
||||
|
||||
if ($ioId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[$ioId][] = $row;
|
||||
|
||||
if ($skuId !== '') {
|
||||
$skuIds[$skuId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$dimensions = $this->importDimensions(array_keys($skuIds));
|
||||
$platform = [];
|
||||
|
||||
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);
|
||||
$shopIId = trim((string) ($row['shop_i_id'] ?? ''));
|
||||
$skuId = trim((string) ($row['sku_id'] ?? ''));
|
||||
|
||||
if ($ioId <= 0 || $shopIId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($skuId !== '') {
|
||||
$platform[$ioId . '|' . $skuId] = $shopIId;
|
||||
}
|
||||
|
||||
$platform[$ioId . '|'] ??= $shopIId;
|
||||
}
|
||||
}
|
||||
|
||||
$contexts = [];
|
||||
|
||||
foreach ($orderByWaybill as $waybill => $order) {
|
||||
$ioId = (int) ($order['io_id'] ?? 0);
|
||||
$contexts[$waybill] = [
|
||||
'order' => $order,
|
||||
'items' => $items[$ioId] ?? [],
|
||||
'dimensions' => $dimensions,
|
||||
'platform' => $platform,
|
||||
];
|
||||
}
|
||||
|
||||
return $contexts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @param list<array{id:int,courier:string,threshold:float,amount:float}> $rules
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function importOverlengthQuoteResult(array $pair, array $context, array $rules): array
|
||||
{
|
||||
$order = $context['order'];
|
||||
$actualCourier = trim((string) ($order['logistics_company'] ?? ''));
|
||||
$importedCourier = $pair['courier_name'];
|
||||
$courier = $importedCourier !== '' ? $importedCourier : $actualCourier;
|
||||
$ioId = (int) ($order['io_id'] ?? 0);
|
||||
$best = null;
|
||||
|
||||
foreach ($context['items'] as $item) {
|
||||
$skuId = trim((string) ($item['sku_id'] ?? ''));
|
||||
$dimension = $context['dimensions'][$skuId] ?? null;
|
||||
|
||||
if ($dimension === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$maxSide = max($dimension['l'], $dimension['w'], $dimension['h']);
|
||||
|
||||
if ($best === null || $maxSide > $best['max_side']) {
|
||||
$best = [
|
||||
'max_side' => $maxSide,
|
||||
'sku_id' => $skuId,
|
||||
'i_id' => trim((string) ($item['i_id'] ?? '')),
|
||||
'length' => $dimension['l'],
|
||||
'width' => $dimension['w'],
|
||||
'height' => $dimension['h'],
|
||||
'platform_product_id' => $context['platform'][$ioId . '|' . $skuId]
|
||||
?? ($context['platform'][$ioId . '|'] ?? null),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'courier_name' => $courier !== '' ? $courier : null,
|
||||
'shipped_at' => trim((string) ($order['io_date'] ?? '')) ?: null,
|
||||
'platform_product_id' => $best['platform_product_id'] ?? null,
|
||||
'product_code' => $best === null
|
||||
? null
|
||||
: ($best['sku_id'] !== '' ? $best['sku_id'] : ($best['i_id'] !== '' ? $best['i_id'] : null)),
|
||||
'shop_name' => trim((string) ($order['shop_name'] ?? '')) ?: null,
|
||||
'online_order_no' => trim((string) ($order['so_id'] ?? '')) ?: null,
|
||||
'length' => $best['length'] ?? null,
|
||||
'width' => $best['width'] ?? null,
|
||||
'height' => $best['height'] ?? null,
|
||||
'volume' => $best === null ? null : round($best['length'] * $best['width'] * $best['height'], 2),
|
||||
'inspection_type' => null,
|
||||
'inspection_reason' => null,
|
||||
'return_waybill_no' => null,
|
||||
'settlement_weight' => null,
|
||||
];
|
||||
|
||||
if ($importedCourier !== '' && $actualCourier !== '' && $importedCourier !== $actualCourier) {
|
||||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||||
'不结:该运单实际快递为「' . $actualCourier . '」,与导入的「' . $importedCourier . '」不一致', null);
|
||||
}
|
||||
|
||||
$rule = null;
|
||||
|
||||
foreach ($rules as $candidate) {
|
||||
if ($candidate['courier'] === $actualCourier) {
|
||||
$rule = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($rule === null) {
|
||||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||||
'不结:快递「' . ($actualCourier !== '' ? $actualCourier : '未知') . '」没有启用的「超长费」报价规则', null);
|
||||
}
|
||||
|
||||
if ($best === null) {
|
||||
return $this->importQuoteRow($pair, $fields, null, '待核价',
|
||||
'待核价:商品资料缺少长宽高,暂无法核算超长费', '销售出库单已匹配,但条形码在商品资料中没有长宽高');
|
||||
}
|
||||
|
||||
$evidence = mb_substr(sprintf(
|
||||
'规则 #%d;条形码 %s 尺寸 %s×%s×%s,最长边 %scm %s %scm',
|
||||
$rule['id'],
|
||||
$best['sku_id'] !== '' ? $best['sku_id'] : '(无)',
|
||||
$this->trimNumber($best['length']),
|
||||
$this->trimNumber($best['width']),
|
||||
$this->trimNumber($best['height']),
|
||||
$this->trimNumber($best['max_side']),
|
||||
$best['max_side'] > $rule['threshold'] ? '>' : '≤',
|
||||
$this->trimNumber($rule['threshold']),
|
||||
), 0, 500);
|
||||
|
||||
if ($best['max_side'] <= $rule['threshold']) {
|
||||
return $this->importQuoteRow($pair, $fields, 0.0, '已计算',
|
||||
'不结:最长边 ' . $this->trimNumber($best['max_side']) . 'cm 未超过报价阈值 ' . $this->trimNumber($rule['threshold']) . 'cm',
|
||||
$evidence);
|
||||
}
|
||||
|
||||
return $this->importQuoteRow($pair, $fields, (float) $rule['amount'], '已计算', '', $evidence);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @param array<string,array<string,list<string>>> $ruleModes
|
||||
* @param array{overlength:list<array<string,mixed>>,return:list<array<string,mixed>>,intercept:list<array<string,mixed>>} $quoteRules
|
||||
* @param array<string,float> $contextWeights
|
||||
* @param array<int,list<array<string,mixed>>> $contextReturns
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function importDeductQuoteResult(
|
||||
array $pair,
|
||||
array $context,
|
||||
?object $fallbackRow,
|
||||
array $ruleModes,
|
||||
array $quoteRules,
|
||||
array $contextWeights,
|
||||
array $contextReturns,
|
||||
): array {
|
||||
$type = $pair['surcharge_type'];
|
||||
$importedCourier = $pair['courier_name'];
|
||||
$contextCourier = trim((string) ($context['courier'] ?? ''));
|
||||
$fallbackCourier = trim((string) ($fallbackRow->courier_name ?? ''));
|
||||
$courier = $importedCourier !== '' ? $importedCourier : ($contextCourier !== '' ? $contextCourier : $fallbackCourier);
|
||||
$inspectionType = trim((string) ($context['inspection_type'] ?? ''));
|
||||
$items = $context['items'] ?? [];
|
||||
$primary = $this->primaryImportItem($items);
|
||||
$orderWeight = $this->importWeightSum($items, $contextWeights) ?? 0.0;
|
||||
$oId = (int) ($context['o_id'] ?? 0);
|
||||
$returnWaybills = [];
|
||||
$hasReturn = false;
|
||||
$returnedWeight = 0.0;
|
||||
$hasReturnWeight = false;
|
||||
|
||||
foreach ($contextReturns[$oId] ?? [] as $return) {
|
||||
$rQty = (float) ($return['r_qty'] ?? 0);
|
||||
$receiveDate = trim((string) ($return['receive_date'] ?? ''));
|
||||
|
||||
if ($rQty <= 0 || $receiveDate === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasReturn = true;
|
||||
$returnWaybill = trim((string) ($return['l_id'] ?? ''));
|
||||
|
||||
if ($returnWaybill !== '') {
|
||||
$returnWaybills[$returnWaybill] = true;
|
||||
}
|
||||
|
||||
$skuId = strtoupper(trim((string) ($return['sku_id'] ?? '')));
|
||||
$weight = $skuId !== '' ? ($contextWeights[$skuId] ?? null) : null;
|
||||
|
||||
if ($weight !== null) {
|
||||
$returnedWeight += $rQty * (float) $weight;
|
||||
$hasReturnWeight = true;
|
||||
}
|
||||
}
|
||||
|
||||
$settlementWeight = $hasReturn
|
||||
? ($hasReturnWeight && $returnedWeight > 0 ? $returnedWeight : $orderWeight)
|
||||
: $orderWeight;
|
||||
$fields = [
|
||||
'courier_name' => $courier !== '' ? $courier : null,
|
||||
'shipped_at' => trim((string) ($context['send_time'] ?? '')) ?: 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' => trim((string) ($context['shop_name'] ?? '')) ?: null,
|
||||
'online_order_no' => trim((string) ($context['online_order_no'] ?? '')) ?: null,
|
||||
'length' => null,
|
||||
'width' => null,
|
||||
'height' => null,
|
||||
'volume' => null,
|
||||
'inspection_type' => $inspectionType !== '' ? $inspectionType : null,
|
||||
'inspection_reason' => trim((string) ($context['inspection_reason'] ?? '')) ?: null,
|
||||
'return_waybill_no' => $returnWaybills === [] ? null : mb_substr((string) array_key_first($returnWaybills), 0, 191),
|
||||
'settlement_weight' => $settlementWeight > 0 ? round($settlementWeight, 2) : null,
|
||||
];
|
||||
|
||||
if ($importedCourier !== '' && $contextCourier !== '' && $importedCourier !== $contextCourier) {
|
||||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||||
'不结:该运单实际快递为「' . $contextCourier . '」,与导入的「' . $importedCourier . '」不一致', null);
|
||||
}
|
||||
|
||||
if ($inspectionType !== '扣单') {
|
||||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||||
'不结:该运单在查件工单里不是「扣单」类型', null);
|
||||
}
|
||||
|
||||
$rules = $type === '拦截费' ? ($quoteRules['intercept'] ?? []) : ($quoteRules['return'] ?? []);
|
||||
$rule = null;
|
||||
|
||||
foreach ($rules as $candidate) {
|
||||
if ($candidate['courier'] === $courier) {
|
||||
$rule = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($rule === null) {
|
||||
$modes = array_values(array_unique($ruleModes[$courier][$type] ?? []));
|
||||
|
||||
if ($modes !== []) {
|
||||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||||
'不结:快递「' . $courier . '」的' . $type . '报价规则(' . implode('、', $modes) . ')暂不支持自动核算', null);
|
||||
}
|
||||
|
||||
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
|
||||
'不结:快递「' . ($courier !== '' ? $courier : '未知') . '」没有启用的「' . $type . '」报价规则', null);
|
||||
}
|
||||
|
||||
if ($type === '拦截费') {
|
||||
return $this->importQuoteRow($pair, $fields, (float) $rule['value'], '已计算', '',
|
||||
'规则 #' . $rule['id'] . ';查件扣单按固定单价 ' . $this->trimNumber((float) $rule['value']) . ' 元/单');
|
||||
}
|
||||
|
||||
$evidenceParts = ['规则 #' . $rule['id']];
|
||||
|
||||
if ($hasReturn) {
|
||||
$evidenceParts[] = '售后退货退款已进仓,结算重量 ' . $this->trimNumber($settlementWeight) . 'kg';
|
||||
} else {
|
||||
$evidenceParts[] = '售后退货退款无进仓时间/实退数量';
|
||||
}
|
||||
|
||||
if ($rule['mode'] === '固定单价') {
|
||||
return $this->importQuoteRow(
|
||||
$pair,
|
||||
$fields,
|
||||
$hasReturn ? (float) $rule['value'] : 0.0,
|
||||
'已计算',
|
||||
$hasReturn ? '' : '不结:售后退货退款无进仓时间/实退数量',
|
||||
implode(';', $evidenceParts),
|
||||
);
|
||||
}
|
||||
|
||||
if (! $hasReturn) {
|
||||
return $this->importQuoteRow($pair, $fields, 0.0, '已计算',
|
||||
'不结:售后退货退款无进仓时间/实退数量', implode(';', $evidenceParts));
|
||||
}
|
||||
|
||||
if ($settlementWeight <= 0) {
|
||||
return $this->importQuoteRow($pair, $fields, null, '待核价',
|
||||
'待核价:商品资料缺少重量,暂无法按重量核算退回费', implode(';', $evidenceParts));
|
||||
}
|
||||
|
||||
return $this->importQuoteRow(
|
||||
$pair,
|
||||
$fields,
|
||||
round($settlementWeight * (float) $rule['value'], 2),
|
||||
'已计算',
|
||||
'',
|
||||
implode(';', $evidenceParts) . ';' . $this->trimNumber($settlementWeight) . 'kg × '
|
||||
. $this->trimNumber((float) $rule['value']) . ' 元/kg',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
|
||||
* @param array<string,mixed> $fields
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function importQuoteRow(
|
||||
array $pair,
|
||||
array $fields,
|
||||
?float $amount,
|
||||
string $status,
|
||||
string $reason,
|
||||
?string $evidence,
|
||||
): array {
|
||||
$decision = $amount !== null && $amount > 0 ? '正常结' : '不结';
|
||||
|
||||
return array_merge([
|
||||
'row_number' => $pair['row_number'],
|
||||
'waybill' => $pair['waybill'],
|
||||
'surcharge_type' => $pair['surcharge_type'],
|
||||
'decision' => $decision,
|
||||
'reason' => $decision === '正常结'
|
||||
? ''
|
||||
: ($reason !== '' ? $reason : '结算金额为 0'),
|
||||
], $fields, [
|
||||
'settlement_amount' => $amount,
|
||||
'settlement_status' => $status,
|
||||
'evidence' => $evidence,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前启用的报价规则里,可用于导入核算的部分。
|
||||
*
|
||||
* @return array{overlength:list<array{id:int,courier:string,threshold:float,amount:float}>,
|
||||
* return:list<array{id:int,courier:string,mode:string,value:float}>,
|
||||
* intercept:list<array{id:int,courier:string,value:float}>}
|
||||
*/
|
||||
private function importQuoteRules(): array
|
||||
{
|
||||
$rules = ['overlength' => [], 'return' => [], 'intercept' => []];
|
||||
|
||||
try {
|
||||
$rows = DB::table('business_courier_surcharge_quotes')
|
||||
->where('status', 'active')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
} catch (Throwable) {
|
||||
return $rules;
|
||||
}
|
||||
|
||||
foreach ($rows as $rule) {
|
||||
$types = json_decode((string) $rule->types, true) ?: [];
|
||||
$courier = trim((string) $rule->courier);
|
||||
$mode = trim((string) $rule->mode);
|
||||
$condition = (string) $rule->condition;
|
||||
$standard = (string) $rule->standard;
|
||||
|
||||
if ($courier === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array('超长费', $types, true)
|
||||
&& (str_contains($mode, '按尺寸') || str_contains($mode, '最长边'))
|
||||
&& preg_match('/单边超\s*(\d+(?:\.\d+)?)\s*(?:cm|厘米|CM|㎝)/u', $condition, $threshold) === 1
|
||||
&& preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/?\s*单/u', $standard, $price) === 1) {
|
||||
$rules['overlength'][] = [
|
||||
'id' => (int) $rule->id,
|
||||
'courier' => $courier,
|
||||
'threshold' => (float) $threshold[1],
|
||||
'amount' => (float) $price[1],
|
||||
];
|
||||
}
|
||||
|
||||
if (! str_contains($condition, '扣单')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array('拦截费', $types, true)
|
||||
&& $mode === '固定单价'
|
||||
&& preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $price) === 1) {
|
||||
$rules['intercept'][] = [
|
||||
'id' => (int) $rule->id,
|
||||
'courier' => $courier,
|
||||
'value' => (float) $price[1],
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array('退回费', $types, true)) {
|
||||
if ($mode === '固定单价'
|
||||
&& preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $price) === 1) {
|
||||
$rules['return'][] = [
|
||||
'id' => (int) $rule->id,
|
||||
'courier' => $courier,
|
||||
'mode' => '固定单价',
|
||||
'value' => (float) $price[1],
|
||||
];
|
||||
} elseif ($mode === '按重量'
|
||||
&& preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/\s*(?:kg|KG|公斤|千克)/u', $standard, $price) === 1) {
|
||||
$rules['return'][] = [
|
||||
'id' => (int) $rule->id,
|
||||
'courier' => $courier,
|
||||
'mode' => '按重量',
|
||||
'value' => (float) $price[1],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $skuIds
|
||||
* @return array<string,array{l:float,w:float,h:float}>
|
||||
*/
|
||||
private function importDimensions(array $skuIds): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
foreach (array_chunk(array_values(array_unique(array_filter($skuIds))), 500) as $chunk) {
|
||||
try {
|
||||
$rows = $this->connection->table('ops_jst_sku')
|
||||
->whereIn('sku_id', $chunk)
|
||||
->get(['sku_id', 'l', 'w', 'h']);
|
||||
} catch (Throwable) {
|
||||
return $map;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$length = (float) $row->l;
|
||||
$width = (float) $row->w;
|
||||
$height = (float) $row->h;
|
||||
|
||||
if ($length > 0 && $width > 0 && $height > 0) {
|
||||
$map[trim((string) $row->sku_id)] = ['l' => $length, 'w' => $width, 'h' => $height];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function trimNumber(float $value): string
|
||||
{
|
||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
|
||||
* @param array<string,array{courier:string}> $returnContext
|
||||
@ -677,14 +1293,23 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
static fn (string $waybill): bool => $waybill !== '',
|
||||
)));
|
||||
|
||||
if ($waybills === [] || count($waybills) > self::IMPORT_RETURN_CONTEXT_LIMIT) {
|
||||
$lookup = app(\Modules\EcommerceFinance\Services\WorkOrderWaybillLookup::class);
|
||||
$indexed = $lookup->ready();
|
||||
if ($waybills === [] || (! $indexed && count($waybills) > self::IMPORT_RETURN_CONTEXT_LIMIT)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$context = [];
|
||||
|
||||
foreach (array_chunk($waybills, 50) as $chunk) {
|
||||
foreach (array_chunk($waybills, $indexed ? 300 : 50) as $chunk) {
|
||||
try {
|
||||
if ($indexed) {
|
||||
$rows = $this->connection->table($lookup::TABLE . ' as lookup')
|
||||
->join('js_jst_work_order_info as raw', 'raw._mirror_key', '=', 'lookup.mirror_key')
|
||||
->whereIn('lookup.waybill', $chunk)
|
||||
->orderByDesc('lookup.source_id')->get(['raw.businessFieldList']);
|
||||
} else {
|
||||
// Retain the existing path only until the resumable initial index is ready.
|
||||
$rows = $this->connection->table('js_jst_work_order_info')
|
||||
->where('title', '查件')
|
||||
->where(function ($query) use ($chunk): void {
|
||||
@ -694,6 +1319,7 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
})
|
||||
->orderByDesc('Id')
|
||||
->get(['businessFieldList']);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
@ -851,7 +1477,7 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
|
||||
/**
|
||||
* @param array<string,array<string,mixed>> $returnContext
|
||||
* @return array<int,string>
|
||||
* @return array<int,list<array{o_id:int,l_id:string,sku_id:string,r_qty:float,receive_date:string}>>
|
||||
*/
|
||||
private function importReturns(array $returnContext): array
|
||||
{
|
||||
@ -882,7 +1508,8 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
|
||||
try {
|
||||
$rows = $upstream->select(
|
||||
'SELECT a.o_id, a.l_id, ri.r_qty, CONVERT(varchar(19), ri.receive_date, 120) AS receive_date '
|
||||
'SELECT a.o_id, a.l_id, ri.sku_id, 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 a.o_id IN (' . $placeholders . ')',
|
||||
@ -904,17 +1531,17 @@ class CourierSurchargeSettlementMirrorRepository
|
||||
continue;
|
||||
}
|
||||
|
||||
$byOrder[$oId][$waybill] = true;
|
||||
$byOrder[$oId][] = [
|
||||
'o_id' => $oId,
|
||||
'l_id' => $waybill,
|
||||
'sku_id' => trim((string) ($row['sku_id'] ?? '')),
|
||||
'r_qty' => (float) ($row['r_qty'] ?? 0),
|
||||
'receive_date' => trim((string) ($row['receive_date'] ?? '')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$map = [];
|
||||
|
||||
foreach ($byOrder as $oId => $waybills) {
|
||||
$map[(int) $oId] = implode(',', array_keys($waybills));
|
||||
}
|
||||
|
||||
return $map;
|
||||
return $byOrder;
|
||||
}
|
||||
|
||||
/** @return array<string,array<string,list<string>>> */
|
||||
|
||||
@ -6,6 +6,7 @@ use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
|
||||
use Modules\EcommerceFinance\Services\WorkOrderWaybillLookup;
|
||||
use RuntimeException;
|
||||
|
||||
class GenericBusinessMirrorRepository
|
||||
@ -161,6 +162,9 @@ class GenericBusinessMirrorRepository
|
||||
foreach (array_chunk($mapped, $chunkSize) as $chunk) {
|
||||
$this->connection->table($dataset->targetTable())->upsert($chunk, ['_mirror_key'], $updates);
|
||||
}
|
||||
if ($dataset->targetTable() === WorkOrderWaybillLookup::SOURCE) {
|
||||
app(WorkOrderWaybillLookup::class)->storeBatch($mapped);
|
||||
}
|
||||
|
||||
$this->connection->table('business_mirror_table_states')
|
||||
->where('dataset_key', $dataset->key())
|
||||
@ -197,6 +201,9 @@ class GenericBusinessMirrorRepository
|
||||
->orWhere('_mirror_generation', '!=', $generation);
|
||||
})
|
||||
->delete();
|
||||
if ($dataset->targetTable() === WorkOrderWaybillLookup::SOURCE) {
|
||||
app(WorkOrderWaybillLookup::class)->prune();
|
||||
}
|
||||
}
|
||||
|
||||
$countState = $this->supportsExactCount()
|
||||
|
||||
@ -4,6 +4,7 @@ namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\EcommerceFinance\JdBillPlatform;
|
||||
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
|
||||
use RuntimeException;
|
||||
@ -28,7 +29,8 @@ class JdDailyBillRepository
|
||||
{
|
||||
$periods = $this->periods();
|
||||
$query = $this->query($filters, $periods);
|
||||
$summary = (clone $query)->selectRaw('COUNT(*) AS count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first();
|
||||
$summary = Cache::remember($this->cacheKey('summary', $periods, $filters), 3600,
|
||||
fn () => (clone $query)->selectRaw('COUNT(*) AS count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first());
|
||||
$total = (int) $summary->count;
|
||||
$page = max(1, $page);
|
||||
$perPage = min(100, max(1, $perPage));
|
||||
@ -64,11 +66,24 @@ class JdDailyBillRepository
|
||||
|
||||
public function filterOptions(): array
|
||||
{
|
||||
$q = $this->query([], $this->periods());
|
||||
$periods = $this->periods();
|
||||
return Cache::remember($this->cacheKey('options', $periods), 3600, function () use ($periods): array {
|
||||
$q = $this->query([], $periods);
|
||||
return [
|
||||
'shops' => (clone $q)->distinct()->orderBy('store_name')->pluck('store_name')->all(),
|
||||
'bill_items' => (clone $q)->distinct()->orderBy('bill_item')->pluck('bill_item')->all(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** Imported generations are immutable; replacing a month invalidates both caches. */
|
||||
private function cacheKey(string $scope, \Illuminate\Support\Collection $periods, array $filters = []): string
|
||||
{
|
||||
ksort($filters);
|
||||
return 'jd-daily-bill:' . $scope . ':v1:' . hash('sha256', json_encode([
|
||||
$this->db()->getDatabaseName(), $this->platform,
|
||||
$periods->pluck('active_generation')->sort()->values()->all(), $filters,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
private function periods(): \Illuminate\Support\Collection
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
|
||||
|
||||
class JushuitanCostPriceObservationRepository
|
||||
{
|
||||
public function __construct(private readonly GenericBusinessMirrorRepository $mirror) {}
|
||||
|
||||
/** 仅当前页每个源商品最近 20 条观测;不混入正式财务取价历史。 */
|
||||
public function attach(array $rows): void
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$row->jushuitan_cost_price_observations = [];
|
||||
}
|
||||
$db = $this->mirror->connection();
|
||||
if ($rows === [] || ! Schema::connection($db->getName())->hasTable(JushuitanCostPriceSnapshotDiffer::OBSERVATIONS)) {
|
||||
return;
|
||||
}
|
||||
$keys = collect($rows)->pluck('_mirror_key')->filter()->unique()->values()->all();
|
||||
if ($keys === []) {
|
||||
return;
|
||||
}
|
||||
$ranked = $db->table(JushuitanCostPriceSnapshotDiffer::OBSERVATIONS)
|
||||
->whereIn('source_key', $keys)
|
||||
->select('*')->selectRaw('ROW_NUMBER() OVER (PARTITION BY source_key ORDER BY observed_at DESC, id DESC) AS observation_rank');
|
||||
$events = $db->query()->fromSub($ranked, 'ranked')->where('observation_rank', '<=', 20)
|
||||
->orderBy('observed_at', 'desc')->get()->groupBy('source_key');
|
||||
foreach ($rows as $row) {
|
||||
$row->jushuitan_cost_price_observations = ($events->get($row->_mirror_key ?? '') ?? collect())->map(static fn ($event) => [
|
||||
'id' => $event->id, 'source' => '镜像价格观测(非完整改价历史)',
|
||||
'record_type_label' => '观测到成本价变化', 'effect_start_time' => $event->observed_at,
|
||||
'before_price' => $event->before_price, 'after_price' => $event->after_price,
|
||||
'diff_amount' => (float) $event->after_price - (float) $event->before_price,
|
||||
'operator' => '-', 'operation' => '镜像快照比较',
|
||||
'remark' => '上次观测:' . $event->previous_observed_at . ';本次为发现时间,不是真实改价时间。',
|
||||
])->all();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -495,13 +495,19 @@ class OrderDataMirrorRepository
|
||||
$shopNames = $this->selectedShopNames($filters);
|
||||
|
||||
if ($shopNames !== []) {
|
||||
// 多店铺:每个店铺做一个模糊匹配再取并集,选中的是完整店铺名时等同于精确匹配。
|
||||
// A release clears the complete cache; the small shop directory remains valid.
|
||||
$known = $this->shopNames();
|
||||
if (! empty($filters['shop_names_exact']) && array_diff($shopNames, $known) === []) {
|
||||
$query->whereIn('o.shop_name', $shopNames);
|
||||
} else {
|
||||
// Free text and legacy callers keep their existing contains semantics.
|
||||
$query->where(function (Builder $shop) use ($shopNames): void {
|
||||
foreach ($shopNames as $name) {
|
||||
$shop->orWhereRaw("o.shop_name LIKE ? ESCAPE '!'", [$this->contains($name)]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (($filters['sku_id'] ?? '') !== '') {
|
||||
// 商品编码同样支持批量:每个编码一组"商品/款式/店铺编码"匹配,多组取并集。
|
||||
$needles = $this->batchValues((string) $filters['sku_id']);
|
||||
|
||||
@ -141,6 +141,7 @@ class ProductCatalogMirrorRepository
|
||||
$this->attachWorkPlatformPrices($rows, $workPlatformPriceSyncStatus['initialized']);
|
||||
$jushuitanCostPriceHistorySyncStatus = $this->jushuitanCostPriceHistorySyncStatus();
|
||||
$this->attachJushuitanCostPriceHistory($rows, $jushuitanCostPriceHistorySyncStatus['initialized']);
|
||||
app(JushuitanCostPriceObservationRepository::class)->attach($rows);
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
@ -571,6 +572,7 @@ class ProductCatalogMirrorRepository
|
||||
$query = $this->connection->table($productTable)
|
||||
->where('sku_type', 'normal')
|
||||
->select([
|
||||
'_mirror_key',
|
||||
'autoid',
|
||||
'i_id',
|
||||
'sku_id',
|
||||
|
||||
@ -11,7 +11,7 @@ use Throwable;
|
||||
* 店铺收款账户账单的「导出任务」:后台生成 CSV 落盘,页面按任务列表查看状态并下载。
|
||||
*
|
||||
* 任务与文件都存在 storage/app/account-bills-exports/{userId}/ 下(JSON 记录状态 + CSV 文件),
|
||||
* 不依赖浏览器保持连接,也不需要队列表:请求里用 detach 的 php-cli 进程跑导出。
|
||||
* 不依赖浏览器保持连接;生产由受限批处理容器逐个消费,本地由单个 PHP 进程消费。
|
||||
*/
|
||||
class AccountBillsExportTasks
|
||||
{
|
||||
@ -29,7 +29,12 @@ class AccountBillsExportTasks
|
||||
|
||||
public function baseDir(int $userId): string
|
||||
{
|
||||
return storage_path('app/account-bills-exports/' . $userId);
|
||||
return $this->rootDir() . '/' . $userId;
|
||||
}
|
||||
|
||||
public function rootDir(): string
|
||||
{
|
||||
return storage_path('app/account-bills-exports');
|
||||
}
|
||||
|
||||
public function jsonPath(array $task): string
|
||||
@ -167,6 +172,8 @@ class AccountBillsExportTasks
|
||||
/** 后台执行导出(脱离当前请求)。 */
|
||||
public function dispatch(array $task): void
|
||||
{
|
||||
// Production consumes the durable file queue from the limited batch profile.
|
||||
if (config('upstream_pipeline.batch_worker_enabled', false)) return;
|
||||
$php = PHP_BINDIR . '/php';
|
||||
if (! is_file($php)) {
|
||||
$php = 'php';
|
||||
@ -174,7 +181,7 @@ class AccountBillsExportTasks
|
||||
|
||||
$artisan = base_path('artisan');
|
||||
$command = escapeshellcmd($php) . ' ' . escapeshellarg($artisan)
|
||||
. ' account-bills:export-run ' . escapeshellarg((string) $task['token'])
|
||||
. ' account-bills:exports:run --max-jobs=0'
|
||||
. ' > /dev/null 2>&1 &';
|
||||
|
||||
try {
|
||||
@ -185,4 +192,15 @@ class AccountBillsExportTasks
|
||||
// 起不了后台进程时保持 queued,页面会提示重试。
|
||||
}
|
||||
}
|
||||
|
||||
public function nextQueued(): ?array
|
||||
{
|
||||
$next = null;
|
||||
foreach (glob($this->rootDir() . '/*/*.json') ?: [] as $path) {
|
||||
$task = json_decode((string) file_get_contents($path), true);
|
||||
if (! is_array($task) || ($task['status'] ?? '') !== 'queued') continue;
|
||||
if ($next === null || strcmp((string) $task['planned_at'], (string) $next['planned_at']) < 0) $next = $task;
|
||||
}
|
||||
return $next;
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,20 +49,20 @@ class AccountMappingResyncDispatcher
|
||||
}
|
||||
|
||||
$mirror = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$months = $mirror->table('store_receipt_order_match_rows')
|
||||
$periods = $mirror->table('store_receipt_order_match_rows')
|
||||
->whereIn('platform', array_values($targets))
|
||||
->selectRaw("DATE_FORMAT(report_month, '%Y-%m') as ym")
|
||||
->select(['platform', 'report_month'])
|
||||
->distinct()
|
||||
->orderBy('ym')
|
||||
->pluck('ym')
|
||||
->all();
|
||||
if ($months === []) {
|
||||
->orderBy('report_month')
|
||||
->get()->groupBy('platform');
|
||||
if ($periods->isEmpty()) {
|
||||
return ['jobs' => 0];
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$jobs = 0;
|
||||
foreach ($targets as $key => $label) {
|
||||
$months = $periods->get($label, collect())->map(fn ($row) => substr((string) $row->report_month, 0, 7))->unique();
|
||||
foreach ($months as $month) {
|
||||
// running 时再次保存映射,会把状态改回 pending;当前进程完成时发现状态已变,
|
||||
// 不会覆盖成 done,于是会紧接着再跑一遍,避免漏掉刚保存的新规则。
|
||||
|
||||
@ -188,12 +188,24 @@ class AccountMappingStore
|
||||
throw new RuntimeException('未找到要修改的规则。');
|
||||
}
|
||||
|
||||
if ($before !== $updated) {
|
||||
$this->persist($rules);
|
||||
$this->logEvent('update', $id, $before, $updated, $userId);
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/** Fee memo is display-only; all other fields retain conservative invalidation. */
|
||||
public static function requiresReceiptResync(?array $before, ?array $after): bool
|
||||
{
|
||||
foreach (self::FIELDS as $field) {
|
||||
if ($field === 'feeMemo') continue;
|
||||
if (trim((string) ($before[$field] ?? '')) !== trim((string) ($after[$field] ?? ''))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function destroy(int $id, ?int $userId = null): bool
|
||||
{
|
||||
$rules = $this->all();
|
||||
@ -222,7 +234,7 @@ class AccountMappingStore
|
||||
* 导入:按「平台 + 账单项目 + 财务备注」判重。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $rows
|
||||
* @return array{created:int,updated:int,skipped:int,duplicates:array<int,string>,total:int}
|
||||
* @return array{created:int,updated:int,skipped:int,duplicates:array<int,string>,total:int,resync_platforms:list<string>}
|
||||
*/
|
||||
public function importRows(array $rows, bool $overwrite, ?int $userId = null): array
|
||||
{
|
||||
@ -242,6 +254,7 @@ class AccountMappingStore
|
||||
$skipped = 0;
|
||||
$duplicates = [];
|
||||
|
||||
$resyncPlatforms = [];
|
||||
foreach ($rows as $row) {
|
||||
$values = $this->normalize($row);
|
||||
if ($this->isEmptyRow($values)) {
|
||||
@ -256,7 +269,16 @@ class AccountMappingStore
|
||||
continue;
|
||||
}
|
||||
$index = $byKey[$key];
|
||||
$rules[$index] = ['id' => $rules[$index]['id']] + $values;
|
||||
$replacement = ['id' => $rules[$index]['id']] + $values;
|
||||
if ($rules[$index] === $replacement) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
if (self::requiresReceiptResync($rules[$index], $replacement)) {
|
||||
$resyncPlatforms[$rules[$index]['platform']] = true;
|
||||
$resyncPlatforms[$replacement['platform']] = true;
|
||||
}
|
||||
$rules[$index] = $replacement;
|
||||
$updated++;
|
||||
continue;
|
||||
}
|
||||
@ -265,6 +287,7 @@ class AccountMappingStore
|
||||
$byKey[$key] = count($rules) - 1;
|
||||
$nextId++;
|
||||
$created++;
|
||||
$resyncPlatforms[$values['platform']] = true;
|
||||
}
|
||||
|
||||
if ($created + $updated > 0) {
|
||||
@ -282,6 +305,7 @@ class AccountMappingStore
|
||||
'skipped' => $skipped,
|
||||
'duplicates' => array_slice($duplicates, 0, 20),
|
||||
'total' => count($rows),
|
||||
'resync_platforms' => array_keys($resyncPlatforms),
|
||||
];
|
||||
}
|
||||
|
||||
@ -510,6 +534,7 @@ class AccountMappingStore
|
||||
/** @param array<int,array<string,mixed>> $rules */
|
||||
private function persist(array $rules): void
|
||||
{
|
||||
self::forgetMemo();
|
||||
if (! $this->rulesTableReady()) {
|
||||
$this->persistFile($rules);
|
||||
|
||||
|
||||
@ -3,9 +3,11 @@
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use PDOException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
@ -64,9 +66,12 @@ class BusinessMirrorSynchronizer
|
||||
$batches = 0;
|
||||
$deadline = microtime(true) + max((int) config('upstream_pipeline.sync.dataset_seconds', 8), 1);
|
||||
$rows = (int) ($state['last_upserted_rows'] ?? 0);
|
||||
$started = microtime(true);
|
||||
$stage = 'read_upstream';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
$stage = 'read_upstream';
|
||||
$batch = $this->readBatch(
|
||||
$dataset,
|
||||
$columns,
|
||||
@ -79,17 +84,24 @@ class BusinessMirrorSynchronizer
|
||||
);
|
||||
|
||||
if ($batch === []) {
|
||||
$stage = 'complete_mirror';
|
||||
$this->mirror->complete($dataset, $mode, $generation, $cursor ?? $highWatermark, $rows);
|
||||
|
||||
return $this->result($dataset, 'succeeded', $mode, $batches, $rows);
|
||||
}
|
||||
|
||||
$stage = 'normalize_rows';
|
||||
$cursor = $this->cursor($dataset, $batch[array_key_last($batch)]);
|
||||
$rows += count($batch);
|
||||
$batchRows = count($batch);
|
||||
$rows += $batchRows;
|
||||
$batches++;
|
||||
$this->mirror->storeBatch($dataset, $columns, $this->normalize($columns, $batch), $cursor, $generation, $rows);
|
||||
$batch = $this->normalize($columns, $batch);
|
||||
$stage = 'write_mirror';
|
||||
$this->mirror->storeBatch($dataset, $columns, $batch, $cursor, $generation, $rows);
|
||||
unset($batch);
|
||||
|
||||
if (count($batch) < $batchSize) {
|
||||
if ($batchRows < $batchSize) {
|
||||
$stage = 'complete_mirror';
|
||||
$this->mirror->complete($dataset, $mode, $generation, $cursor, $rows);
|
||||
|
||||
return $this->result($dataset, 'succeeded', $mode, $batches, $rows);
|
||||
@ -100,6 +112,28 @@ class BusinessMirrorSynchronizer
|
||||
}
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
// PDOException alone cannot distinguish an upstream timeout from a
|
||||
// local transaction failure. Keep the stage and numeric diagnostics,
|
||||
// never the exception message, SQL, bindings or connection details.
|
||||
$sqlState = null;
|
||||
$driverCode = null;
|
||||
for ($cause = $exception; $cause !== null; $cause = $cause->getPrevious()) {
|
||||
if ($cause instanceof PDOException) {
|
||||
$code = (string) ($cause->errorInfo[0] ?? $cause->getCode());
|
||||
$sqlState = preg_match('/^[A-Z0-9]{5}$/D', $code) === 1 ? $code : null;
|
||||
$native = $cause->errorInfo[1] ?? null;
|
||||
$driverCode = is_numeric($native) ? (int) $native : null;
|
||||
}
|
||||
}
|
||||
Log::warning('共享业务镜像同步阶段失败。', [
|
||||
'dataset' => $dataset->key(),
|
||||
'source' => $dataset->sourceKey(),
|
||||
'stage' => $stage,
|
||||
'sql_state' => $sqlState,
|
||||
'driver_code' => $driverCode,
|
||||
'elapsed_seconds' => round(microtime(true) - $started, 3),
|
||||
'exception' => $exception::class,
|
||||
]);
|
||||
$this->mirror->fail($dataset, $exception::class);
|
||||
throw $exception;
|
||||
}
|
||||
@ -157,33 +191,49 @@ class BusinessMirrorSynchronizer
|
||||
implode(', ', array_map(fn (array $column): string => $this->selectExpression($column), $columns)), $tail, $limit,
|
||||
);
|
||||
|
||||
if ($this->driver === 'sqlsrv' && $mode === 'incremental'
|
||||
&& in_array($dataset->key(), (array) config('upstream_mirrors.table_sync.key_first_datasets', []), true)) {
|
||||
$keys = (new UpstreamSqlDialect($this->driver))->select(
|
||||
implode(', ', array_map(fn (string $name): string => $this->quote($name), $order)), $tail, $limit,
|
||||
);
|
||||
$keyColumn = fn (string $name): string => $this->quote('mirror_keys') . '.' . $this->quote($name);
|
||||
$rowColumn = fn (string $name): string => $this->quote('mirror_rows') . '.' . $this->quote($name);
|
||||
$sql = 'SELECT ' . implode(', ', array_map(fn (array $column): string => $this->selectExpression($column, 'mirror_rows'), $columns))
|
||||
. ' FROM (' . $keys . ') AS ' . $this->quote('mirror_keys')
|
||||
. ' INNER LOOP JOIN ' . $this->quote($dataset->sourceSchema()) . '.' . $this->quote($dataset->sourceObject())
|
||||
. ' AS ' . $this->quote('mirror_rows')
|
||||
. ' ON ' . implode(' AND ', array_map(fn (string $name): string => $rowColumn($name) . ' = ' . $keyColumn($name), $order))
|
||||
. ' ORDER BY ' . implode(', ', array_map($keyColumn, $order));
|
||||
}
|
||||
|
||||
return $this->queries->forSource($dataset->sourceKey())->select($sql, $bindings);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $column */
|
||||
private function selectExpression(array $column): string
|
||||
private function selectExpression(array $column, ?string $alias = null): string
|
||||
{
|
||||
$name = (string) $column['column_name'];
|
||||
$quoted = $this->quote($name);
|
||||
$reference = $alias === null ? $quoted : $this->quote($alias) . '.' . $quoted;
|
||||
|
||||
if ($this->driver !== 'sqlsrv') {
|
||||
if ($this->driver === 'pgsql' && ($column['data_type'] ?? '') === 'timestamp with time zone') {
|
||||
// Store timezone-aware PostgreSQL instants as UTC in the shared mirror.
|
||||
return "to_char({$quoted} AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US') AS {$quoted}";
|
||||
return "to_char({$reference} AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US') AS {$quoted}";
|
||||
}
|
||||
if ($this->driver === 'pgsql' && ($column['data_type'] ?? '') === 'bytea') {
|
||||
return "encode({$quoted}, 'hex') AS {$quoted}";
|
||||
return "encode({$reference}, 'hex') AS {$quoted}";
|
||||
}
|
||||
return $quoted;
|
||||
return $reference;
|
||||
}
|
||||
|
||||
// FreeTDS may return SQL Server date values using the connection locale
|
||||
// (for example "Jan 1 2026"). Convert temporal columns at the source so
|
||||
// every mirror receives deterministic ISO values that MariaDB accepts.
|
||||
return match (strtolower((string) ($column['data_type'] ?? ''))) {
|
||||
'date' => 'CONVERT(varchar(10), ' . $quoted . ', 23) AS ' . $quoted,
|
||||
'datetime', 'datetime2', 'smalldatetime' => 'CONVERT(varchar(27), ' . $quoted . ', 126) AS ' . $quoted,
|
||||
default => $quoted,
|
||||
'date' => 'CONVERT(varchar(10), ' . $reference . ', 23) AS ' . $quoted,
|
||||
'datetime', 'datetime2', 'smalldatetime' => 'CONVERT(varchar(27), ' . $reference . ', 126) AS ' . $quoted,
|
||||
default => $reference,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
|
||||
use Throwable;
|
||||
|
||||
/** 当前镜像的观测记录;不写正式操作日志,也不推测真实改价时间或历史价格。 */
|
||||
class JushuitanCostPriceSnapshotDiffer
|
||||
{
|
||||
public const DATASET_KEY = 'jushuitan.product-cost-price-observations';
|
||||
public const SNAPSHOTS = 'jushuitan_cost_price_observation_baselines';
|
||||
public const OBSERVATIONS = 'jushuitan_cost_price_observations';
|
||||
public const STATES = 'business_mirror_table_states';
|
||||
|
||||
public function __construct(
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
) {}
|
||||
|
||||
public function sync(int $batchSize = 500, int $maxBatches = 20): array
|
||||
{
|
||||
$lock = Cache::lock('jushuitan-cost-price-observations', 1800);
|
||||
if (! $lock->get()) {
|
||||
return ['status' => 'locked', 'rows' => 0, 'changes' => 0];
|
||||
}
|
||||
try {
|
||||
return $this->run(max(1, min(1000, $batchSize)), max(1, min(2000, $maxBatches)));
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function run(int $batchSize, int $maxBatches): array
|
||||
{
|
||||
$db = $this->mirror->connection();
|
||||
$schema = Schema::connection($db->getName());
|
||||
$dataset = $this->datasets->get(ProductCatalogMirrorRepository::DATASET_KEY);
|
||||
if (! $this->mirror->ready($dataset)
|
||||
|| ! $schema->hasTable(self::SNAPSHOTS) || ! $schema->hasTable(self::OBSERVATIONS)) {
|
||||
return ['status' => 'source_not_ready', 'rows' => 0, 'changes' => 0];
|
||||
}
|
||||
$source = $this->mirror->state($dataset);
|
||||
if (($source['status'] ?? '') !== 'succeeded') {
|
||||
return ['status' => 'source_not_ready', 'rows' => 0, 'changes' => 0];
|
||||
}
|
||||
$state = $db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->first();
|
||||
$resume = $state && in_array($state->status, ['running', 'failed'], true);
|
||||
$cursor = $resume ? json_decode($state->cursor ?? 'null', true) : null;
|
||||
// 固定窗口、两分钟重叠,兼容源镜像批次时间精度;重复读取不重复记变价。
|
||||
$from = $resume ? $state->window_started_at
|
||||
: ($state?->window_ends_at ? Carbon::parse($state->window_ends_at)->subMinutes(2)->format('Y-m-d H:i:s.u') : null);
|
||||
$until = $resume ? $state->window_ends_at : now()->subSeconds(2)->format('Y-m-d H:i:s.u');
|
||||
$db->table(self::STATES)->updateOrInsert(['dataset_key' => self::DATASET_KEY], [
|
||||
'source_key' => 'operations_replica', 'source_object' => 'mirror.' . $dataset->targetTable(),
|
||||
'target_table' => self::OBSERVATIONS, 'status' => 'running',
|
||||
'run_mode' => $state?->initial_completed_at ? 'incremental' : 'baseline',
|
||||
'generation' => $resume ? $state->generation : (string) Str::uuid(),
|
||||
'window_started_at' => $from, 'window_ends_at' => $until,
|
||||
'cursor' => $cursor ? json_encode($cursor) : null,
|
||||
'last_attempted_at' => now(), 'last_error_code' => null, 'updated_at' => now(),
|
||||
]);
|
||||
$scanned = $changes = 0;
|
||||
try {
|
||||
for ($batch = 0; $batch < $maxBatches; $batch++) {
|
||||
$query = $db->table($dataset->targetTable())
|
||||
->select(['_mirror_key', '_mirror_synced_at', 'sku_id', 'cost_price', 'modified'])
|
||||
->where('sku_type', 'normal')->whereNotNull('cost_price')
|
||||
->where('_mirror_synced_at', '<=', $until);
|
||||
if ($from !== null) {
|
||||
$query->where('_mirror_synced_at', '>=', $from);
|
||||
}
|
||||
if ($cursor) {
|
||||
$query->where(static fn ($q) => $q->where('_mirror_synced_at', '>', $cursor['at'])
|
||||
->orWhere(static fn ($q) => $q->where('_mirror_synced_at', $cursor['at'])->where('_mirror_key', '>', $cursor['key'])));
|
||||
}
|
||||
$rows = $query->orderBy('_mirror_synced_at')->orderBy('_mirror_key')->limit($batchSize)->get();
|
||||
if ($rows->isEmpty()) {
|
||||
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
|
||||
'status' => 'succeeded', 'cursor' => null, 'high_watermark' => json_encode(['at' => $until]),
|
||||
'initial_completed_at' => $state?->initial_completed_at ?? now(),
|
||||
'last_succeeded_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
return ['status' => 'succeeded', 'rows' => $scanned, 'changes' => $changes];
|
||||
}
|
||||
$previous = $db->table(self::SNAPSHOTS)->whereIn('source_key', $rows->pluck('_mirror_key'))->get()->keyBy('source_key');
|
||||
$snapshots = $events = [];
|
||||
$observedAt = now()->format('Y-m-d H:i:s.u');
|
||||
foreach ($rows as $row) {
|
||||
$sku = trim((string) $row->sku_id);
|
||||
if ($sku === '' || ! is_numeric($row->cost_price)) {
|
||||
continue;
|
||||
}
|
||||
$price = number_format((float) $row->cost_price, 4, '.', '');
|
||||
$old = $previous->get($row->_mirror_key);
|
||||
if ($old && $old->sku_id === $sku && number_format((float) $old->cost_price, 4, '.', '') !== $price) {
|
||||
$events[] = [
|
||||
'id' => hash('sha256', $row->_mirror_key . '|' . $old->captured_at . '|' . $observedAt . '|' . $price),
|
||||
'source_key' => $row->_mirror_key, 'sku_id' => $sku,
|
||||
'before_price' => $old->cost_price, 'after_price' => $price,
|
||||
'previous_observed_at' => $old->captured_at, 'observed_at' => $observedAt,
|
||||
'source_modified_at' => $row->modified,
|
||||
];
|
||||
}
|
||||
$snapshots[] = ['source_key' => $row->_mirror_key, 'sku_id' => $sku, 'cost_price' => $price, 'captured_at' => $observedAt];
|
||||
}
|
||||
$last = $rows->last();
|
||||
$cursor = ['at' => $last->_mirror_synced_at, 'key' => $last->_mirror_key];
|
||||
// 价格基线、观测事件和游标同一事务;中断续传不会漏写或重复记账。
|
||||
$db->transaction(function () use ($db, $snapshots, $events, $cursor): void {
|
||||
if ($snapshots) {
|
||||
$db->table(self::SNAPSHOTS)->upsert($snapshots, ['source_key'], ['sku_id', 'cost_price', 'captured_at']);
|
||||
}
|
||||
if ($events) {
|
||||
$db->table(self::OBSERVATIONS)->insert($events);
|
||||
}
|
||||
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
|
||||
'cursor' => json_encode($cursor), 'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
$scanned += $rows->count();
|
||||
$changes += count($events);
|
||||
}
|
||||
return ['status' => 'running', 'rows' => $scanned, 'changes' => $changes];
|
||||
} catch (Throwable $exception) {
|
||||
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
|
||||
'status' => 'failed', 'last_error_code' => 'snapshot_comparison_failed', 'updated_at' => now(),
|
||||
]);
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,9 +14,9 @@ use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
|
||||
* 种菜损耗(2026-10-19 财务确认的口径):
|
||||
* 1. 取 M 月种菜报销明细(完成时间 [M-02, M+1-02));
|
||||
* 2. 用线上订单号去匹配发货数据(jst_oms_shipping)中 M-1、M 两个月发货的交易单号,匹配到的保留;
|
||||
* 3. 匹配到的单里,任务备注或卖家备注写了“拍A发B”或“拍A发A”的不扣款,单独列出发给运营;
|
||||
* 3. 任务备注或卖家备注写了“拍A发B”或“拍A发A”的不扣款,不论是否匹配到发货都单独列出发给运营;
|
||||
* 4. 实际只发了「信誉订单虚拟条码」「透明挂钩」这类商品的不扣款;
|
||||
* 5. 其余匹配到的作为扣款项,扣款金额 = Σ 发货商品成本价 × 数量 × 1.2(成本价+成本价×20%),
|
||||
* 5. 其余匹配到的作为扣款项,扣款金额 = Σ 发货商品成本价 × 数量 + 该订单在发货数据中的运费(2026-10-19 更正),
|
||||
* 成本价按发货条形码(=商品编码)取普通商品资料。未匹配到发货的不计入种菜损耗。
|
||||
*/
|
||||
class PlantingLossCalculator
|
||||
@ -32,10 +32,16 @@ class PlantingLossCalculator
|
||||
public const RESULTS = [self::RESULT_DEDUCT, self::RESULT_EXEMPT, self::RESULT_FREE_GOODS, self::RESULT_UNMATCHED];
|
||||
|
||||
/** 发货商品名称或条码含这些字样的不计扣款。 */
|
||||
public const FREE_GOODS_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩'];
|
||||
public const FREE_GOODS_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩', '无实物'];
|
||||
|
||||
/** 扣款 = 成本价 × (1 + 20%)。 */
|
||||
public const MARKUP_PERCENT = 20;
|
||||
/**
|
||||
* 信誉订单虚拟条码(种菜单自身的虚拟商品)。发货数据里同一交易单号下若出现它,说明这单本身是按虚拟条码走的,
|
||||
* 同单号下的其它商品是合并发货的别的订单,整单不扣款(2026-10-19 财务确认:信誉订单虚拟条码不扣款)。
|
||||
* 透明挂钩同理:同单号下出现透明挂钩即整单不扣款(2026-10-20 财务确认)。
|
||||
*/
|
||||
public const VIRTUAL_ORDER_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩'];
|
||||
|
||||
public const VIRTUAL_ORDER_BARCODES = ['X00006', 'X00002'];
|
||||
|
||||
public const SKU_DATASET = 'operations_replica.rpt.jst_sku';
|
||||
|
||||
@ -104,10 +110,26 @@ class PlantingLossCalculator
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 成本价(元,字符串)× 数量 × 1.2 → 分。 */
|
||||
public static function isVirtualOrderItem(string $barcode, string $productName): bool
|
||||
{
|
||||
$code = strtoupper(trim($barcode));
|
||||
// 透明挂钩条形码以 GG 开头(如 GG003-SZ / GG-xxx),2026-10-20 财务确认。
|
||||
if (in_array($code, self::VIRTUAL_ORDER_BARCODES, true) || str_starts_with($code, 'GG')) {
|
||||
return true;
|
||||
}
|
||||
foreach (self::VIRTUAL_ORDER_KEYWORDS as $keyword) {
|
||||
if (str_contains($productName, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 成本价(元,字符串)× 数量 → 分。 */
|
||||
public static function deductionCents(string $costPrice, int $qty): int
|
||||
{
|
||||
return (int) round((float) $costPrice * 100 * $qty * (100 + self::MARKUP_PERCENT) / 100);
|
||||
return (int) round((float) $costPrice * 100 * $qty);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -156,7 +178,13 @@ class PlantingLossCalculator
|
||||
$deductLines = [];
|
||||
$missingCost = [];
|
||||
$cents = 0;
|
||||
$virtualOrder = false;
|
||||
foreach ($shipment['lines'] ?? [] as $line) {
|
||||
if (self::isVirtualOrderItem($line['barcode'], $line['product_name'])) {
|
||||
$virtualOrder = true;
|
||||
}
|
||||
}
|
||||
foreach ($virtualOrder ? [] : ($shipment['lines'] ?? []) as $line) {
|
||||
if (self::isFreeGoods($line['barcode'], $line['product_name'])) {
|
||||
continue;
|
||||
}
|
||||
@ -168,23 +196,39 @@ class PlantingLossCalculator
|
||||
}
|
||||
$lineCents = self::deductionCents($cost, $line['qty']);
|
||||
$cents += $lineCents;
|
||||
$deductLines[] = sprintf('%s × %d × %s × 1.2 = %s', $line['barcode'], $line['qty'], $cost, number_format($lineCents / 100, 2, '.', ''));
|
||||
$deductLines[] = sprintf('%s × %d × %s = %s', $line['barcode'], $line['qty'], $cost, number_format($lineCents / 100, 2, '.', ''));
|
||||
}
|
||||
$freight = $shipment['freight'] ?? null;
|
||||
if ($deductLines !== []) {
|
||||
if ($freight === null) {
|
||||
$deductLines[] = '运费缺失';
|
||||
} else {
|
||||
$cents += (int) round((float) $freight * 100);
|
||||
$deductLines[] = '运费 ' . $freight;
|
||||
}
|
||||
}
|
||||
$missing = array_values(array_unique($missingCost));
|
||||
$missingText = implode(';', array_filter([
|
||||
$missing === [] ? '' : '成本价缺失:' . implode('、', $missing),
|
||||
$deductLines !== [] && $freight === null ? '运费缺失' : '',
|
||||
]));
|
||||
|
||||
$keyword = $shipment === null ? null : self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']);
|
||||
// 拍A发B/拍A发A 不论是否匹配到发货都单独列出发给运营(2026-10-20 财务确认);匹配发货只用于挑出真实发货的单。
|
||||
$keyword = self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']);
|
||||
[$result, $basis] = match (true) {
|
||||
$keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”' . ($shipment === null ? '(未匹配到发货)' : '')],
|
||||
$shipment === null => [self::RESULT_UNMATCHED, self::shippingLabel($month) . ' 发货数据中无此交易单号'],
|
||||
$keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”'],
|
||||
$deductLines === [] => [self::RESULT_FREE_GOODS, '只发了虚拟条码/透明挂钩'],
|
||||
$missingCost !== [] => [self::RESULT_DEDUCT, '成本价缺失:' . implode('、', array_unique($missingCost)) . ',扣款金额未含该商品'],
|
||||
default => [self::RESULT_DEDUCT, '已匹配发货,按成本价×1.2扣款'],
|
||||
$virtualOrder => [self::RESULT_FREE_GOODS, '发货含信誉订单虚拟条码/透明挂钩,整单不扣款'],
|
||||
$deductLines === [] => [self::RESULT_FREE_GOODS, '只发了虚拟条码/透明挂钩/无实物商品'],
|
||||
$missingText !== '' => [self::RESULT_DEDUCT, $missingText . ',扣款金额未含缺失部分'],
|
||||
default => [self::RESULT_DEDUCT, '已匹配发货,按成本价×数量+运费扣款'],
|
||||
};
|
||||
|
||||
$row['loss_result'] = $result;
|
||||
$row['loss_basis'] = $basis;
|
||||
$row['deduction_amount'] = $result === self::RESULT_DEDUCT ? number_format($cents / 100, 2, '.', '') : null;
|
||||
$row['deduction_detail'] = $result === self::RESULT_DEDUCT ? implode("\n", $deductLines) : '';
|
||||
$row['cost_missing'] = $result === self::RESULT_DEDUCT && $missingCost !== [];
|
||||
$row['cost_missing'] = $result === self::RESULT_DEDUCT && $missingText !== '';
|
||||
$row['shipped_at'] = $shipment['shipped_at'] ?? '';
|
||||
$row['express_no'] = $shipment['express_no'] ?? '';
|
||||
$row['warehouse_name'] = $shipment['warehouse_name'] ?? '';
|
||||
|
||||
@ -457,6 +457,15 @@ class StoreReportMirrorSynchronizer
|
||||
* @return array{status:string,source_rows:int,stored_rows:int,changed:bool}
|
||||
*/
|
||||
public function syncPaidSales(bool $force = false): array
|
||||
{
|
||||
$result = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock('store-report:paid-sales', 600)
|
||||
->get(fn () => $this->syncPaidSalesUnlocked($force));
|
||||
return $result === false
|
||||
? ['status' => 'succeeded', 'source_rows' => 0, 'stored_rows' => 0, 'changed' => false]
|
||||
: $result;
|
||||
}
|
||||
|
||||
private function syncPaidSalesUnlocked(bool $force): array
|
||||
{
|
||||
if (! Schema::connection($this->connection->getName())
|
||||
->hasTable(StoreReportRepository::PAID_SALES_SOURCE_TABLE)) {
|
||||
@ -464,8 +473,17 @@ class StoreReportMirrorSynchronizer
|
||||
}
|
||||
|
||||
$state = $this->state(StoreReportRepository::PAID_SALES_DATASET);
|
||||
$changes = app(StoreReportPaidSalesChanges::class);
|
||||
try {
|
||||
$fingerprint = $this->paidSalesFingerprint();
|
||||
if (! $force && $changes->available() && ! empty($state->active_generation)
|
||||
&& ! empty($state->initial_completed_at)
|
||||
&& str_starts_with((string) ($state->source_fingerprint ?? ''), StoreReportPaidSalesChanges::VERSION)) {
|
||||
return $this->syncPaidSalesDates($state, $changes);
|
||||
}
|
||||
return $this->connection->transaction(function () use ($state, $force, $changes): array {
|
||||
$pending = $changes->available() ? $changes->pending() : [];
|
||||
$fingerprint = $changes->available()
|
||||
? StoreReportPaidSalesChanges::VERSION . Str::uuid() : $this->paidSalesFingerprint();
|
||||
$active = trim((string) ($state->active_generation ?? ''));
|
||||
if (! $force && $active !== '' && ! empty($state->initial_completed_at)
|
||||
&& (string) ($state->source_fingerprint ?? '') === $fingerprint) {
|
||||
@ -511,8 +529,10 @@ class StoreReportMirrorSynchronizer
|
||||
// 版本切换成功后再清理上一版,避免中途失败时页面读到被清空的当前版本。
|
||||
$this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
|
||||
->where('generation', '<>', $generation)->delete();
|
||||
if ($changes->available()) $changes->acknowledge($pending);
|
||||
|
||||
return ['status' => 'succeeded', 'source_rows' => $sourceRows, 'stored_rows' => $stored, 'changed' => true];
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
$this->connection->table('store_report_import_states')
|
||||
->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)
|
||||
@ -532,6 +552,41 @@ class StoreReportMirrorSynchronizer
|
||||
}
|
||||
}
|
||||
|
||||
private function syncPaidSalesDates(object $state, StoreReportPaidSalesChanges $changes): array
|
||||
{
|
||||
$pending = $changes->pending();
|
||||
if ($pending === []) {
|
||||
return ['status' => 'succeeded', 'source_rows' => 0, 'stored_rows' => (int) $state->row_count, 'changed' => false];
|
||||
}
|
||||
$dates = array_column($pending, 'occurred_on');
|
||||
$generation = (string) $state->active_generation;
|
||||
return $this->connection->transaction(function () use ($changes, $pending, $dates, $generation): array {
|
||||
$this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
|
||||
->where('generation', $generation)->whereIn('occurred_on', $dates)->delete();
|
||||
$this->connection->statement(
|
||||
'INSERT INTO ' . StoreReportRepository::PAID_SALES_TABLE
|
||||
. ' (generation, store_name, occurred_on, paid_amount, order_count, updated_at)'
|
||||
. ' SELECT ?, store_name, occurred_date,'
|
||||
. ' SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
|
||||
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), COUNT(*), ?'
|
||||
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE
|
||||
. " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IN ("
|
||||
. implode(',', array_fill(0, count($dates), '?')) . ') GROUP BY store_name, occurred_date',
|
||||
[$generation, now()->format('Y-m-d H:i:s'), ...$dates],
|
||||
);
|
||||
$stored = (int) $this->connection->table(StoreReportRepository::PAID_SALES_TABLE)->where('generation', $generation)->count();
|
||||
$sourceRows = (int) $this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)->whereIn('occurred_date', $dates)->count();
|
||||
$this->connection->table('store_report_import_states')
|
||||
->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)->update([
|
||||
'status' => 'succeeded', 'run_mode' => 'incremental', 'row_count' => $stored,
|
||||
'source_fingerprint' => StoreReportPaidSalesChanges::VERSION . Str::uuid(),
|
||||
'last_attempted_at' => now(), 'last_succeeded_at' => now(), 'last_error_code' => null, 'updated_at' => now(),
|
||||
]);
|
||||
$changes->acknowledge($pending);
|
||||
return ['status' => 'succeeded', 'source_rows' => $sourceRows, 'stored_rows' => $stored, 'changed' => true];
|
||||
});
|
||||
}
|
||||
|
||||
/** 业绩明细是否变化的指纹:行数 + 最近写入时间 + 销量口径四项金额合计。 */
|
||||
private function paidSalesFingerprint(): string
|
||||
{
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
|
||||
|
||||
/** Persistent invalidation by affected date, including dates removed by a period replacement. */
|
||||
class StoreReportPaidSalesChanges
|
||||
{
|
||||
public const TABLE = 'store_report_paid_sales_dirty_dates';
|
||||
public const VERSION = 'dates:v1:';
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(DatabaseManager $database)
|
||||
{
|
||||
$this->connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
}
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
return $this->connection->getSchemaBuilder()->hasTable(self::TABLE);
|
||||
}
|
||||
|
||||
public function markPeriod(string $reportMonth, string $platform): void
|
||||
{
|
||||
if (! $this->available()) return;
|
||||
$dates = $this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)
|
||||
->where('report_month', substr($reportMonth, 0, 7) . '-01')->where('platform', $platform)
|
||||
->whereNotNull('occurred_date')->distinct()->pluck('occurred_date');
|
||||
$this->markDates($dates->all());
|
||||
}
|
||||
|
||||
public function markDates(array $dates): void
|
||||
{
|
||||
if (! $this->available()) return;
|
||||
$revision = (string) Str::uuid();
|
||||
$rows = array_map(fn ($date) => ['occurred_on' => $date, 'revision' => $revision], array_values(array_unique(array_filter($dates))));
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
$this->connection->table(self::TABLE)->upsert($chunk, ['occurred_on'], ['revision']);
|
||||
}
|
||||
}
|
||||
|
||||
public function pending(): array
|
||||
{
|
||||
return $this->connection->table(self::TABLE)->orderBy('occurred_on')->get()->map(fn ($row) => (array) $row)->all();
|
||||
}
|
||||
|
||||
public function acknowledge(array $snapshot): void
|
||||
{
|
||||
foreach (array_chunk($snapshot, 100) as $chunk) {
|
||||
// A write arriving during aggregation retains its newer revision for the next run.
|
||||
$this->connection->table(self::TABLE)->where(function ($query) use ($chunk): void {
|
||||
foreach ($chunk as $row) {
|
||||
$query->orWhere(fn ($match) => $match->where('occurred_on', $row['occurred_on'])->where('revision', $row['revision']));
|
||||
}
|
||||
})->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/** 财务端货款对账比对汇总 CSV。 */
|
||||
class SupplierComparisonCsvExporter
|
||||
{
|
||||
private const HEADERS = [
|
||||
'采购单号', '日期', '我方商品编码', '厂家条码', '比对结果',
|
||||
'我方数量', '我方单价', '我方金额',
|
||||
'厂家数量', '厂家单价', '厂家金额',
|
||||
'数量差异', '金额差异', '结算口径', '结算金额',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $detail
|
||||
*/
|
||||
public function download(
|
||||
array $detail,
|
||||
string $supplier,
|
||||
string $month,
|
||||
string $reconciler,
|
||||
): StreamedResponse {
|
||||
$safeSupplier = preg_replace('/[\\\\\\/:*?"<>|]+/u', '_', $supplier) ?: '厂家';
|
||||
$period = $month !== '' ? preg_replace('/[^0-9-]+/u', '', $month) : '全部月份';
|
||||
$filename = $safeSupplier . '_货款对账比对_' . $period . '_' . now()->format('Ymd_His') . '.csv';
|
||||
$summary = (array) ($detail['summary'] ?? []);
|
||||
$rows = (array) ($detail['rows'] ?? []);
|
||||
|
||||
return response()->streamDownload(function () use ($detail, $rows, $summary, $supplier, $month, $reconciler): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
if ($csv === false) {
|
||||
throw new RuntimeException('无法创建导出文件。');
|
||||
}
|
||||
|
||||
fwrite($csv, "\xEF\xBB\xBF");
|
||||
$this->write($csv, ['供应商', $supplier, '月份', $month !== '' ? $month : '全部月份', '对账人', $reconciler !== '' ? $reconciler : '全部']);
|
||||
$this->write($csv, [
|
||||
'我方数据行数', (int) ($detail['mineRows'] ?? 0),
|
||||
'厂家数据行数', (int) ($detail['theirsRows'] ?? 0),
|
||||
'比对结果条数', count($rows),
|
||||
]);
|
||||
$this->write($csv, [
|
||||
'双方一致', (int) ($summary['match'] ?? 0),
|
||||
'厂家多', (int) ($summary['supplierExtra'] ?? 0),
|
||||
'数量/金额不一致', (int) ($summary['mismatch'] ?? 0),
|
||||
'厂家少', (int) ($summary['supplierMissing'] ?? 0),
|
||||
]);
|
||||
$this->write($csv, [
|
||||
'我方金额合计', $this->money($summary['mineAmount'] ?? 0),
|
||||
'厂家金额合计', $this->money($summary['theirsAmount'] ?? 0),
|
||||
'厂家多算金额', $this->money($summary['supplierOverAmount'] ?? 0),
|
||||
'按口径结算合计', $this->money($summary['settleAmount'] ?? 0),
|
||||
'差异金额', $this->money($summary['differenceAmount'] ?? 0),
|
||||
]);
|
||||
$this->write($csv, []);
|
||||
$this->write($csv, self::HEADERS);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$this->write($csv, [
|
||||
$row['purchaseNo'] ?? '',
|
||||
$row['date'] ?? '',
|
||||
$row['barcode'] ?? '',
|
||||
$row['supplierBarcode'] ?? '',
|
||||
$row['type'] ?? '',
|
||||
$this->number($row['mineQty'] ?? 0),
|
||||
$this->money($row['mineUnitPrice'] ?? 0),
|
||||
$this->money($row['mineAmount'] ?? 0),
|
||||
$this->number($row['theirsQty'] ?? 0),
|
||||
$this->money($row['theirsUnitPrice'] ?? 0),
|
||||
$this->money($row['theirsAmount'] ?? 0),
|
||||
$this->quantityDifference($row['qtyDifference'] ?? 0),
|
||||
$this->amountDifference($row['amountDifference'] ?? 0),
|
||||
$row['note'] ?? '',
|
||||
$this->money($row['settleAmount'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($csv);
|
||||
}, $filename, [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param resource $csv @param list<mixed> $values */
|
||||
private function write($csv, array $values): void
|
||||
{
|
||||
fputcsv($csv, array_map(fn (mixed $value): string => $this->csvCell($value), $values));
|
||||
}
|
||||
|
||||
private function quantityDifference(mixed $value): string
|
||||
{
|
||||
$difference = (float) $value;
|
||||
if (abs($difference) < 0.005) {
|
||||
return '一致';
|
||||
}
|
||||
|
||||
return '厂家' . ($difference > 0 ? '多 ' : '少 ') . $this->number(abs($difference));
|
||||
}
|
||||
|
||||
private function amountDifference(mixed $value): string
|
||||
{
|
||||
$difference = (float) $value;
|
||||
if (abs($difference) < 0.005) {
|
||||
return '一致';
|
||||
}
|
||||
|
||||
return '厂家' . ($difference > 0 ? '多 ¥' : '少 ¥') . $this->money(abs($difference));
|
||||
}
|
||||
|
||||
private function number(mixed $value): string
|
||||
{
|
||||
return rtrim(rtrim(number_format((float) $value, 2, '.', ''), '0'), '.');
|
||||
}
|
||||
|
||||
private function money(mixed $value): string
|
||||
{
|
||||
return number_format((float) $value, 2, '.', '');
|
||||
}
|
||||
|
||||
private function csvCell(mixed $value): string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
if (is_numeric(str_replace(',', '', $text))) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return preg_match('/^[=+\-@\t\r]/u', $text) === 1 ? "\t" . $text : $text;
|
||||
}
|
||||
}
|
||||
@ -165,7 +165,7 @@ class SupplierDingTalkNotifier
|
||||
'- 对账月份:' . ($month !== '' ? $month : '全部月份'),
|
||||
'- 对账人:' . ($reconciler !== '' ? $reconciler : '全部'),
|
||||
'- 差异共 **' . $summary['count'] . '** 条(厂家多 ' . $summary['extra']
|
||||
. ' / 金额数量不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
|
||||
. ' / 数量金额不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
|
||||
'- 差异金额:**¥' . number_format($summary['difference_amount'], 2) . '**',
|
||||
];
|
||||
|
||||
@ -179,22 +179,29 @@ class SupplierDingTalkNotifier
|
||||
$theirsQty = (float) ($row['theirsQty'] ?? 0);
|
||||
$mineAmount = (float) ($row['mineAmount'] ?? 0);
|
||||
$theirsAmount = (float) ($row['theirsAmount'] ?? 0);
|
||||
$mineUnitPrice = (float) ($row['mineUnitPrice'] ?? $row['unitPrice'] ?? 0);
|
||||
$theirsUnitPrice = (float) ($row['theirsUnitPrice'] ?? $row['unitPrice'] ?? 0);
|
||||
$purchaseNo = trim((string) ($row['purchaseNo'] ?? ''));
|
||||
$date = trim((string) ($row['date'] ?? ''));
|
||||
$barcode = trim((string) ($row['barcode'] ?? ''));
|
||||
$supplierBarcode = trim((string) ($row['supplierBarcode'] ?? ''));
|
||||
|
||||
$lines[] = sprintf(
|
||||
'%d. **%s** `%s`%s 单价 ¥%s|我方 %s / ¥%s|厂家 %s / ¥%s|%s',
|
||||
'%d. **%s**|采购单号:%s|日期:%s|商品编码:%s%s',
|
||||
$rowOffset + $index + 1,
|
||||
(string) ($row['type'] ?? '差异'),
|
||||
$purchaseNo !== '' ? $purchaseNo : '—',
|
||||
$date !== '' ? $date : '—',
|
||||
$barcode,
|
||||
$supplierBarcode !== '' ? '(厂家条码 ' . $supplierBarcode . ')' : '',
|
||||
number_format((float) ($row['unitPrice'] ?? 0), 2),
|
||||
$this->number($mineQty),
|
||||
number_format($mineAmount, 2),
|
||||
$this->number($theirsQty),
|
||||
number_format($theirsAmount, 2),
|
||||
(string) ($row['note'] ?? ''),
|
||||
);
|
||||
$lines[] = ' 我方:数量 ' . $this->number($mineQty)
|
||||
. '|单价 ¥' . number_format($mineUnitPrice, 2)
|
||||
. '|金额 ¥' . number_format($mineAmount, 2);
|
||||
$lines[] = ' 厂家:数量 ' . $this->number($theirsQty)
|
||||
. '|单价 ¥' . number_format($theirsUnitPrice, 2)
|
||||
. '|金额 ¥' . number_format($theirsAmount, 2);
|
||||
$lines[] = ' 处理:' . (string) ($row['note'] ?? '');
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
|
||||
133
modules/EcommerceFinance/Services/SupplierReturnAccounting.php
Normal file
133
modules/EcommerceFinance/Services/SupplierReturnAccounting.php
Normal file
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
/**
|
||||
* 退货记账归属:同一笔退货数据同时供财务端和厂家端展示。
|
||||
*
|
||||
* 备注中出现“记账物流”时,表示该部分不扣厂家、转物流记账;
|
||||
* 当前终版数据由业务先调整厂家净数量/金额,备注保留原始数量及转运数量。
|
||||
*/
|
||||
class SupplierReturnAccounting
|
||||
{
|
||||
/** @param array<string,mixed> $row */
|
||||
public function enrich(array $row): array
|
||||
{
|
||||
$remark = trim((string) ($row['remark'] ?? ''));
|
||||
$title = trim((string) ($row['title'] ?? ''));
|
||||
$isLogistics = preg_match('/记账物流|物流记账|物流.*不记账|不记账.*物流/u', $remark) === 1;
|
||||
$isNoBookkeeping = ! $isLogistics && str_contains($remark, '不记账');
|
||||
$target = $isLogistics ? '物流' : ($isNoBookkeeping ? '不记账' : '厂家');
|
||||
|
||||
$logisticsQty = 0.0;
|
||||
$originalQty = 0.0;
|
||||
if ($isLogistics) {
|
||||
if (preg_match('/\*\s*(\d+(?:\.\d+)?)\s*记账物流/u', $remark, $matches) === 1
|
||||
|| preg_match('/记账物流\s*[×xX*]?\s*(\d+(?:\.\d+)?)/u', $remark, $matches) === 1) {
|
||||
$logisticsQty = (float) $matches[1];
|
||||
}
|
||||
if (preg_match('/原数量\s*(\d+(?:\.\d+)?)/u', $remark, $matches) === 1) {
|
||||
$originalQty = (float) $matches[1];
|
||||
}
|
||||
if ($logisticsQty === 0.0 && $originalQty > 0) {
|
||||
$logisticsQty = max(0.0, $originalQty - $this->number($row['qty'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
$qty = $this->number($row['qty'] ?? 0);
|
||||
$unitPrice = $this->number($row['unit_price'] ?? $row['guess_unit_price'] ?? 0);
|
||||
$amount = $this->number($row['amount'] ?? $row['guess_amount'] ?? 0);
|
||||
if ($amount === 0.0 && $qty !== 0.0) {
|
||||
$amount = $qty * $unitPrice;
|
||||
}
|
||||
if ($unitPrice === 0.0 && $qty !== 0.0) {
|
||||
$unitPrice = $amount / $qty;
|
||||
}
|
||||
|
||||
$supplierQty = $qty;
|
||||
$supplierAmount = $amount;
|
||||
$amountContainsLogistics = $originalQty > 0
|
||||
&& $logisticsQty > 0
|
||||
&& abs($amount - ($originalQty * $unitPrice)) < 0.01;
|
||||
if ($amountContainsLogistics) {
|
||||
$supplierQty = max(0.0, $originalQty - $logisticsQty);
|
||||
$supplierAmount = max(0.0, $amount - ($logisticsQty * $unitPrice));
|
||||
}
|
||||
|
||||
$row['_accounting_target'] = $target;
|
||||
$row['_accounting_label'] = $isLogistics ? '转物流记账' : ($isNoBookkeeping ? '不记账' : '厂家扣减');
|
||||
$row['_accounting_note'] = $isLogistics
|
||||
? '不扣本厂家,转' . ($title !== '' ? $title : '物流') . '记账'
|
||||
: ($isNoBookkeeping ? '本厂家不记账' : '按厂家退货扣减');
|
||||
$row['_logistics_qty'] = $isLogistics ? $logisticsQty : 0.0;
|
||||
$row['_logistics_amount'] = $isLogistics
|
||||
? round($logisticsQty * $unitPrice, 2)
|
||||
: 0.0;
|
||||
$row['_logistics_company'] = $isLogistics ? $title : '';
|
||||
$row['_supplier_qty'] = round($supplierQty, 2);
|
||||
$row['_supplier_amount'] = round($supplierAmount, 2);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return array{count:int,supplier_rows:int,supplier_qty:float,supplier_amount:float,logistics_rows:int,logistics_qty:float,logistics_amount:float,logistics_companies:list<string>,no_bookkeeping_rows:int}
|
||||
*/
|
||||
public function summarize(array $rows): array
|
||||
{
|
||||
$summary = [
|
||||
'count' => count($rows),
|
||||
'supplier_rows' => 0,
|
||||
'supplier_qty' => 0.0,
|
||||
'supplier_amount' => 0.0,
|
||||
'logistics_rows' => 0,
|
||||
'logistics_qty' => 0.0,
|
||||
'logistics_amount' => 0.0,
|
||||
'logistics_companies' => [],
|
||||
'no_bookkeeping_rows' => 0,
|
||||
];
|
||||
$companies = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = $this->enrich($row);
|
||||
$target = (string) $row['_accounting_target'];
|
||||
|
||||
if ($target === '不记账') {
|
||||
$summary['no_bookkeeping_rows']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['supplier_rows']++;
|
||||
$summary['supplier_qty'] += (float) $row['_supplier_qty'];
|
||||
$summary['supplier_amount'] += (float) $row['_supplier_amount'];
|
||||
|
||||
if ($target !== '物流') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary['logistics_rows']++;
|
||||
$summary['logistics_qty'] += (float) $row['_logistics_qty'];
|
||||
$summary['logistics_amount'] += (float) $row['_logistics_amount'];
|
||||
$company = trim((string) $row['_logistics_company']);
|
||||
if ($company !== '') {
|
||||
$companies[$company] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$summary['supplier_qty'] = round($summary['supplier_qty'], 2);
|
||||
$summary['supplier_amount'] = round($summary['supplier_amount'], 2);
|
||||
$summary['logistics_qty'] = round($summary['logistics_qty'], 2);
|
||||
$summary['logistics_amount'] = round($summary['logistics_amount'], 2);
|
||||
$summary['logistics_companies'] = array_keys($companies);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function number(mixed $value): float
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
|
||||
return is_numeric(str_replace(',', '', $raw)) ? (float) str_replace(',', '', $raw) : 0.0;
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,9 @@ namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
/**
|
||||
* 厂家对账单 vs 我方结算数据:
|
||||
* - 行键 = 条码(去掉厂家编号/版本后缀,如 CJ019312N1097V01 → CJ019312)+ 单价
|
||||
* (同一把钥匙的多行先合并:数量、金额相加);
|
||||
* - 四类结果:一致 / 厂家多(不结算、退回)/ 金额不一致(按金额低的一方结算)/ 厂家少(按厂家数据结算,即不付款)。
|
||||
* - 行键 = 采购单号 + 日期 + 我方商品编码(对应厂家条码去掉厂家编号/版本后缀);
|
||||
* - 同一行键的多行先合并数量与金额,再比较双方数量和金额;
|
||||
* - 厂家未填采购单号时,仅在同日期+商品编码能唯一对应我方一条数据时兜底匹配。
|
||||
*/
|
||||
class SupplierStatementMatcher
|
||||
{
|
||||
@ -14,7 +14,7 @@ class SupplierStatementMatcher
|
||||
|
||||
public const TYPE_SUPPLIER_EXTRA = '厂家多';
|
||||
|
||||
public const TYPE_MISMATCH = '金额不一致';
|
||||
public const TYPE_MISMATCH = '数量/金额不一致';
|
||||
|
||||
public const TYPE_SUPPLIER_MISSING = '厂家少';
|
||||
|
||||
@ -22,74 +22,129 @@ class SupplierStatementMatcher
|
||||
private const SUPPLIER_SUFFIX = '/[A-Z]{1,3}\d{3,6}V\d{1,3}$/i';
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $mine 我方结算数据(货款对账:skuCode/unitPrice/qty/amount)
|
||||
* @param list<array<string,mixed>> $theirs 厂家对账单(barcode/unitPrice/qty/amount)
|
||||
* @param list<array<string,mixed>> $mine 我方数据(purchaseNo/date/productCode/unitPrice/qty/amount;productCode 为空才回退货号)
|
||||
* @param list<array<string,mixed>> $theirs 厂家数据(purchaseNo/createdAt/barcode/unitPrice/qty/amount)
|
||||
* @return array{rows:list<array<string,mixed>>,summary:array<string,mixed>}
|
||||
*/
|
||||
public function compare(array $mine, array $theirs): array
|
||||
{
|
||||
$mineGrouped = $this->group($mine, 'skuCode');
|
||||
$theirsGrouped = $this->group($theirs, 'barcode');
|
||||
$mineGrouped = $this->group($mine, ['productCode', 'skuCode'], 'purchaseNo', 'date');
|
||||
$theirsGrouped = $this->group($theirs, ['barcode'], 'purchaseNo', 'createdAt');
|
||||
|
||||
// 已有采购单号时严格按三元组匹配;缺失采购单号时,仅在日期+商品编码唯一时兜底。
|
||||
$fallbackIndex = [];
|
||||
foreach ($mineGrouped as $key => $row) {
|
||||
$fallbackIndex[$row['date'] . "\0" . $row['barcodeKey']][] = $key;
|
||||
}
|
||||
$theirsGrouped = $this->resolveMissingPurchaseNumbers($theirsGrouped, $fallbackIndex);
|
||||
|
||||
$rows = [];
|
||||
$summary = ['match' => 0, 'supplierExtra' => 0, 'mismatch' => 0, 'supplierMissing' => 0,
|
||||
'mineAmount' => 0.0, 'settleAmount' => 0.0];
|
||||
$summary = [
|
||||
'match' => 0,
|
||||
'supplierExtra' => 0,
|
||||
'mismatch' => 0,
|
||||
'supplierMissing' => 0,
|
||||
'mineAmount' => 0.0,
|
||||
'theirsAmount' => 0.0,
|
||||
'supplierOverQty' => 0.0,
|
||||
'supplierOverAmount' => 0.0,
|
||||
'settleAmount' => 0.0,
|
||||
];
|
||||
|
||||
foreach (array_unique(array_merge(array_keys($mineGrouped), array_keys($theirsGrouped))) as $key) {
|
||||
$mineRow = $mineGrouped[$key] ?? null;
|
||||
$theirsRow = $theirsGrouped[$key] ?? null;
|
||||
|
||||
$barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode']);
|
||||
$supplierBarcode = (string) ($theirsRow['barcode'] ?? '');
|
||||
$unitPrice = (float) ($mineRow['unitPrice'] ?? $theirsRow['unitPrice']);
|
||||
$purchaseNo = (string) ($mineRow['purchaseNo'] ?? $theirsRow['purchaseNo'] ?? '');
|
||||
$date = (string) ($mineRow['date'] ?? $theirsRow['date'] ?? '');
|
||||
$barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode'] ?? '');
|
||||
$supplierBarcode = (string) ($theirsRow['supplierBarcode'] ?? '');
|
||||
$mineUnitPrice = (float) ($mineRow['unitPrice'] ?? 0);
|
||||
$theirsUnitPrice = (float) ($theirsRow['unitPrice'] ?? 0);
|
||||
$unitPrice = $mineUnitPrice > 0 ? $mineUnitPrice : $theirsUnitPrice;
|
||||
$mineQty = (float) ($mineRow['qty'] ?? 0);
|
||||
$mineAmount = (float) ($mineRow['amount'] ?? 0);
|
||||
$theirsQty = (float) ($theirsRow['qty'] ?? 0);
|
||||
$theirsAmount = (float) ($theirsRow['amount'] ?? 0);
|
||||
$qtyDifference = $theirsQty - $mineQty;
|
||||
$amountDifference = $theirsAmount - $mineAmount;
|
||||
$quantityDifference = $this->describeDifference($qtyDifference, $amountDifference);
|
||||
$fallbackNote = '';
|
||||
if (($theirsRow['matchedByFallback'] ?? false) === true) {
|
||||
$fallbackNote = '厂家未填采购单号,已按日期+商品编码唯一匹配;';
|
||||
} elseif (($theirsRow['fallbackAmbiguous'] ?? false) === true) {
|
||||
$fallbackNote = '厂家未填采购单号,且同日期+商品编码有多条我方数据,无法唯一匹配;';
|
||||
}
|
||||
|
||||
if ($mineRow === null) {
|
||||
$type = self::TYPE_SUPPLIER_EXTRA;
|
||||
$settle = 0.0;
|
||||
$note = '我方没有这条数据:不结算,退回厂家修改账单';
|
||||
$note = $fallbackNote . '厂家整条多报:' . $quantityDifference . ';我方没有该采购单号+日期+商品编码,不结算';
|
||||
$summary['supplierExtra']++;
|
||||
} elseif ($theirsRow === null) {
|
||||
$type = self::TYPE_SUPPLIER_MISSING;
|
||||
$settle = 0.0;
|
||||
$note = '厂家少报了这条数据:按厂家数据结算(不付款)';
|
||||
$note = '厂家漏报:我方数量 ' . $this->number($mineQty)
|
||||
. ',单价 ¥' . number_format($mineUnitPrice, 2)
|
||||
. ',金额 ¥' . number_format($mineAmount, 2)
|
||||
. ';按厂家数据结算(不付款)';
|
||||
$summary['supplierMissing']++;
|
||||
} elseif (abs($mineAmount - $theirsAmount) < 0.005 && abs($mineQty - $theirsQty) < 0.005) {
|
||||
$type = self::TYPE_MATCH;
|
||||
$settle = $mineAmount;
|
||||
$note = '正常结算';
|
||||
$note = $fallbackNote . '采购单号、日期、商品编码、数量和金额一致,正常结算';
|
||||
$summary['match']++;
|
||||
} else {
|
||||
$type = self::TYPE_MISMATCH;
|
||||
$settle = min($mineAmount, $theirsAmount);
|
||||
$note = '金额/数量不一致:按金额低的一方结算';
|
||||
$note = $fallbackNote . $quantityDifference . ';按金额低的一方结算,请厂家修改';
|
||||
$summary['mismatch']++;
|
||||
}
|
||||
|
||||
$summary['mineAmount'] += $mineAmount;
|
||||
$summary['theirsAmount'] += $theirsAmount;
|
||||
$summary['supplierOverQty'] += max($qtyDifference, 0);
|
||||
$summary['supplierOverAmount'] += max($amountDifference, 0);
|
||||
$summary['settleAmount'] += $settle;
|
||||
|
||||
$rows[] = [
|
||||
'purchaseNo' => $purchaseNo,
|
||||
'date' => $date,
|
||||
'barcode' => $barcode,
|
||||
'supplierBarcode' => $supplierBarcode === $barcode ? '' : $supplierBarcode,
|
||||
'supplierBarcode' => $supplierBarcode !== '' && $supplierBarcode !== $barcode ? $supplierBarcode : '',
|
||||
'unitPrice' => $unitPrice,
|
||||
'mineUnitPrice' => $mineUnitPrice,
|
||||
'theirsUnitPrice' => $theirsUnitPrice,
|
||||
'mineQty' => $mineQty,
|
||||
'mineAmount' => $mineAmount,
|
||||
'theirsQty' => $theirsQty,
|
||||
'theirsAmount' => $theirsAmount,
|
||||
'qtyDifference' => round($qtyDifference, 4),
|
||||
'amountDifference' => round($amountDifference, 2),
|
||||
'supplierOverQty' => round(max($qtyDifference, 0), 4),
|
||||
'supplierOverAmount' => round(max($amountDifference, 0), 2),
|
||||
'type' => $type,
|
||||
'settleAmount' => $settle,
|
||||
'note' => $note,
|
||||
];
|
||||
}
|
||||
|
||||
usort($rows, static fn (array $a, array $b): int => [$a['type'] === self::TYPE_MATCH ? 1 : 0, $a['barcode']]
|
||||
<=> [$b['type'] === self::TYPE_MATCH ? 1 : 0, $b['barcode']]);
|
||||
usort($rows, static fn (array $a, array $b): int => [
|
||||
$a['type'] === self::TYPE_MATCH ? 1 : 0,
|
||||
$a['date'],
|
||||
$a['purchaseNo'],
|
||||
$a['barcode'],
|
||||
] <=> [
|
||||
$b['type'] === self::TYPE_MATCH ? 1 : 0,
|
||||
$b['date'],
|
||||
$b['purchaseNo'],
|
||||
$b['barcode'],
|
||||
]);
|
||||
|
||||
$summary['mineAmount'] = round($summary['mineAmount'], 2);
|
||||
$summary['theirsAmount'] = round($summary['theirsAmount'], 2);
|
||||
$summary['supplierOverQty'] = round($summary['supplierOverQty'], 4);
|
||||
$summary['supplierOverAmount'] = round($summary['supplierOverAmount'], 2);
|
||||
$summary['settleAmount'] = round($summary['settleAmount'], 2);
|
||||
$summary['differenceAmount'] = round($summary['mineAmount'] - $summary['settleAmount'], 2);
|
||||
|
||||
@ -97,36 +152,176 @@ class SupplierStatementMatcher
|
||||
}
|
||||
|
||||
/**
|
||||
* 按「条码 + 单价」归组,数量与金额相加。
|
||||
* 按「采购单号 + 日期 + 商品编码」归组,数量与金额相加。
|
||||
*
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return array<string,array{barcode:string,unitPrice:float,qty:float,amount:float}>
|
||||
* @param list<string> $barcodeFields
|
||||
* @return array<string,array<string,mixed>>
|
||||
*/
|
||||
private function group(array $rows, string $barcodeField): array
|
||||
private function group(array $rows, array $barcodeFields, string $purchaseField, string $dateField): array
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$barcode = trim((string) ($row[$barcodeField] ?? ''));
|
||||
$barcode = $this->barcodeValue($row, $barcodeFields);
|
||||
if ($barcode === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$barcodeKey = $this->normalizeBarcode($barcode);
|
||||
$purchaseNo = $this->normalizePurchaseNo($row[$purchaseField] ?? '');
|
||||
$date = $this->normalizeDate($row[$dateField] ?? '');
|
||||
$unitPrice = (float) ($row['unitPrice'] ?? 0);
|
||||
$key = $this->normalizeBarcode($barcode) . '|' . number_format($unitPrice, 4, '.', '');
|
||||
$key = $this->matchKey($purchaseNo, $date, $barcodeKey);
|
||||
|
||||
$grouped[$key] ??= ['barcode' => $barcode, 'unitPrice' => $unitPrice, 'qty' => 0.0, 'amount' => 0.0];
|
||||
$grouped[$key] ??= [
|
||||
'purchaseNo' => $purchaseNo,
|
||||
'date' => $date,
|
||||
'barcode' => $barcode,
|
||||
'barcodeKey' => $barcodeKey,
|
||||
'supplierBarcode' => in_array('barcode', $barcodeFields, true) ? $barcode : '',
|
||||
'unitPrice' => $unitPrice,
|
||||
'unitPrices' => [],
|
||||
'qty' => 0.0,
|
||||
'amount' => 0.0,
|
||||
'matchedByFallback' => false,
|
||||
'fallbackAmbiguous' => false,
|
||||
];
|
||||
if ($unitPrice > 0) {
|
||||
$grouped[$key]['unitPrices'][number_format($unitPrice, 4, '.', '')] = true;
|
||||
}
|
||||
$grouped[$key]['qty'] += (float) ($row['qty'] ?? 0);
|
||||
$grouped[$key]['amount'] += (float) ($row['amount'] ?? 0);
|
||||
}
|
||||
|
||||
foreach ($grouped as &$group) {
|
||||
$group = $this->finalizeGroup($group);
|
||||
}
|
||||
unset($group);
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row @param list<string> $fields */
|
||||
private function barcodeValue(array $row, array $fields): string
|
||||
{
|
||||
foreach ($fields as $field) {
|
||||
$value = trim((string) ($row[$field] ?? ''));
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array<string,mixed>> $groups
|
||||
* @param array<string,list<string>> $fallbackIndex
|
||||
* @return array<string,array<string,mixed>>
|
||||
*/
|
||||
private function resolveMissingPurchaseNumbers(array $groups, array $fallbackIndex): array
|
||||
{
|
||||
$resolved = [];
|
||||
foreach ($groups as $key => $group) {
|
||||
$targetKey = $key;
|
||||
if ($group['purchaseNo'] === '') {
|
||||
$candidates = $fallbackIndex[$group['date'] . "\0" . $group['barcodeKey']] ?? [];
|
||||
if (count($candidates) === 1) {
|
||||
$targetKey = $candidates[0];
|
||||
$group['matchedByFallback'] = true;
|
||||
} elseif (count($candidates) > 1) {
|
||||
$group['fallbackAmbiguous'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($resolved[$targetKey])) {
|
||||
$resolved[$targetKey] = $this->mergeGroup($resolved[$targetKey], $group);
|
||||
} else {
|
||||
$resolved[$targetKey] = $group;
|
||||
}
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $left @param array<string,mixed> $right */
|
||||
private function mergeGroup(array $left, array $right): array
|
||||
{
|
||||
$left['qty'] += (float) $right['qty'];
|
||||
$left['amount'] += (float) $right['amount'];
|
||||
$left['unitPrices'] += (array) $right['unitPrices'];
|
||||
$left['matchedByFallback'] = (bool) $left['matchedByFallback'] || (bool) $right['matchedByFallback'];
|
||||
$left['fallbackAmbiguous'] = (bool) $left['fallbackAmbiguous'] || (bool) $right['fallbackAmbiguous'];
|
||||
if ($left['supplierBarcode'] === '' && $right['supplierBarcode'] !== '') {
|
||||
$left['supplierBarcode'] = $right['supplierBarcode'];
|
||||
}
|
||||
|
||||
return $this->finalizeGroup($left);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $group @return array<string,mixed> */
|
||||
private function finalizeGroup(array $group): array
|
||||
{
|
||||
$prices = array_keys((array) ($group['unitPrices'] ?? []));
|
||||
if (count($prices) === 1) {
|
||||
$group['unitPrice'] = (float) $prices[0];
|
||||
} elseif ((float) $group['qty'] != 0.0) {
|
||||
$group['unitPrice'] = round((float) $group['amount'] / (float) $group['qty'], 4);
|
||||
}
|
||||
|
||||
return $group;
|
||||
}
|
||||
|
||||
private function matchKey(string $purchaseNo, string $date, string $barcode): string
|
||||
{
|
||||
return $purchaseNo . "\0" . $date . "\0" . $barcode;
|
||||
}
|
||||
|
||||
private function normalizePurchaseNo(mixed $value): string
|
||||
{
|
||||
$purchaseNo = ltrim(trim((string) $value), "'");
|
||||
|
||||
return preg_match('/^(\d+)\.0+$/', $purchaseNo, $matches) === 1 ? $matches[1] : $purchaseNo;
|
||||
}
|
||||
|
||||
private function normalizeDate(mixed $value): string
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
if (preg_match('/^(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})/', $raw, $matches) === 1) {
|
||||
$year = (int) $matches[1];
|
||||
$month = (int) $matches[2];
|
||||
$day = (int) $matches[3];
|
||||
if (checkdate($month, $day, $year)) {
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
private function describeDifference(float $qtyDifference, float $amountDifference): string
|
||||
{
|
||||
$parts = [];
|
||||
if (abs($qtyDifference) >= 0.005) {
|
||||
$parts[] = '厂家数量' . ($qtyDifference > 0 ? '多 ' : '少 ') . $this->number(abs($qtyDifference));
|
||||
}
|
||||
if (abs($amountDifference) >= 0.005) {
|
||||
$parts[] = '厂家金额' . ($amountDifference > 0 ? '多 ¥' : '少 ¥') . number_format(abs($amountDifference), 2);
|
||||
}
|
||||
|
||||
return $parts === [] ? '数量、金额一致' : implode(',', $parts);
|
||||
}
|
||||
|
||||
private function number(float $value): string
|
||||
{
|
||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
}
|
||||
|
||||
/** 去掉条码末尾的「厂家编号 + 版本」后缀,双方口径统一后再匹配。 */
|
||||
private function normalizeBarcode(string $barcode): string
|
||||
{
|
||||
$normalized = preg_replace(self::SUPPLIER_SUFFIX, '', $barcode);
|
||||
|
||||
return $normalized === null || $normalized === '' ? $barcode : $normalized;
|
||||
return strtoupper($normalized === null || $normalized === '' ? $barcode : $normalized);
|
||||
}
|
||||
}
|
||||
|
||||
102
modules/EcommerceFinance/Services/WorkOrderWaybillLookup.php
Normal file
102
modules/EcommerceFinance/Services/WorkOrderWaybillLookup.php
Normal file
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
|
||||
/** A small index into the existing raw mirror; the original JSON stays authoritative. */
|
||||
class WorkOrderWaybillLookup
|
||||
{
|
||||
public const SOURCE = 'js_jst_work_order_info';
|
||||
public const TABLE = 'work_order_waybill_lookup';
|
||||
public const STATE = 'work_order_waybill_lookup_states';
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
private ?bool $available = null;
|
||||
|
||||
public function __construct(DatabaseManager $database)
|
||||
{
|
||||
$this->connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
}
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
return $this->available ??= $this->connection->getSchemaBuilder()->hasTable(self::TABLE)
|
||||
&& $this->connection->getSchemaBuilder()->hasTable(self::STATE);
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
return $this->available() && $this->connection->table(self::STATE)->where('id', 1)->whereNotNull('completed_at')->exists();
|
||||
}
|
||||
|
||||
/** Called inside the raw mirror's transaction, including changes to title/waybill. */
|
||||
public function storeBatch(array $rows): void
|
||||
{
|
||||
if ($rows === [] || ! $this->available()) return;
|
||||
$keys = [];
|
||||
$values = [];
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
$key = (string) $row['_mirror_key'];
|
||||
$keys[] = $key;
|
||||
if (($row['title'] ?? '') !== '查件') continue;
|
||||
$fields = json_decode((string) ($row['businessFieldList'] ?? ''), true);
|
||||
if (! is_array($fields)) continue;
|
||||
$waybill = '';
|
||||
// Same last-field-wins semantics as the existing settlement parser.
|
||||
foreach ($fields as $field) {
|
||||
if (is_array($field) && ($field['fieldId'] ?? null) === 'lIdField') {
|
||||
$waybill = trim((string) ($field['value'] ?? ''));
|
||||
}
|
||||
}
|
||||
if ($waybill !== '' && mb_strlen($waybill) <= 191) {
|
||||
$values[] = ['mirror_key' => $key, 'waybill' => $waybill, 'source_id' => (int) ($row['Id'] ?? 0)];
|
||||
}
|
||||
}
|
||||
// Remove renamed/invalid records too, so an old waybill never survives an update.
|
||||
$this->connection->table(self::TABLE)->whereIn('mirror_key', $keys)->delete();
|
||||
foreach (array_chunk($values, 500) as $chunk) {
|
||||
$this->connection->table(self::TABLE)->insert($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
public function prune(): void
|
||||
{
|
||||
if (! $this->available()) return;
|
||||
$this->connection->table(self::TABLE)->whereNotExists(function ($query): void {
|
||||
$query->selectRaw('1')->from(self::SOURCE)
|
||||
->whereColumn(self::SOURCE . '._mirror_key', self::TABLE . '.mirror_key');
|
||||
})->delete();
|
||||
}
|
||||
|
||||
/** Resume a bounded keyset pass; production invokes this through the global sync lock. */
|
||||
public function backfill(int $batchSize = 500, int $maxBatches = 2, int $pauseMs = 50): array
|
||||
{
|
||||
if (! $this->available()) throw new \RuntimeException('work_order_waybill_lookup_not_migrated');
|
||||
$state = $this->connection->table(self::STATE)->where('id', 1)->first();
|
||||
if ($state?->completed_at) return ['complete' => true, 'rows' => 0, 'batches' => 0];
|
||||
$cursor = $state?->cursor;
|
||||
$rows = $batches = 0;
|
||||
$batchSize = min(max(1, $batchSize), 1000);
|
||||
do {
|
||||
$batch = $this->connection->table(self::SOURCE)->when($cursor, fn ($query) => $query->where('_mirror_key', '>', $cursor))
|
||||
->orderBy('_mirror_key')->limit($batchSize)->get(['_mirror_key', 'Id', 'title', 'businessFieldList']);
|
||||
$complete = $batch->count() < $batchSize;
|
||||
$this->connection->transaction(function () use ($batch, $complete, &$cursor): void {
|
||||
$this->storeBatch($batch->all());
|
||||
$cursor = $batch->last()?->_mirror_key ?? $cursor;
|
||||
if ($complete) $this->prune();
|
||||
$this->connection->table(self::STATE)->updateOrInsert(['id' => 1], [
|
||||
'cursor' => $cursor, 'completed_at' => $complete ? now() : null, 'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
$rows += $batch->count();
|
||||
$batches++;
|
||||
if (! $complete && $pauseMs > 0) usleep(min($pauseMs, 1000) * 1000);
|
||||
} while (! $complete && ($maxBatches === 0 || $batches < $maxBatches));
|
||||
|
||||
return compact('complete', 'rows', 'batches');
|
||||
}
|
||||
}
|
||||
@ -81,6 +81,7 @@ class OrderDataPageData
|
||||
// shop_name 保留单值形态,兼容只认单值的调用方;多选统一用 shop_names。
|
||||
'shop_name' => $shopNames[0] ?? '',
|
||||
'shop_names' => $shopNames,
|
||||
'shop_names_exact' => ! empty($input['shop_names_exact']) || is_array($input['shop_name'] ?? null),
|
||||
'sku_id' => $this->batchText($input['sku_id'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
|
||||
use Modules\EcommerceFinance\Services\PlantingLossCalculator as Calculator;
|
||||
|
||||
/** 种菜数据汇总 → 种菜损耗:种菜报销明细匹配近两个月发货数据,拍A发B/拍A发A、只发虚拟条码/透明挂钩的不扣款,其余按成本价×1.2 扣款。 */
|
||||
/** 种菜数据汇总 → 种菜损耗:种菜报销明细匹配近两个月发货数据,拍A发B/拍A发A、只发虚拟条码/透明挂钩的不扣款,其余按成本价×数量+运费扣款。 */
|
||||
class PlantingLossPageData
|
||||
{
|
||||
use PlantingPageFilters;
|
||||
@ -25,7 +25,7 @@ class PlantingLossPageData
|
||||
];
|
||||
|
||||
public const COLUMNS = [
|
||||
'loss_result' => '损耗判定', 'deduction_amount' => '扣款金额', 'loss_basis' => '判定说明', 'deduction_detail' => '扣款计算(条码×数量×成本价×1.2)',
|
||||
'loss_result' => '损耗判定', 'deduction_amount' => '扣款金额', 'loss_basis' => '判定说明', 'deduction_detail' => '扣款计算(条码×数量×成本价+运费)',
|
||||
'completed_at' => '报销完成时间', 'work_order_no' => '工单编号', 'platform' => '平台', 'shop_name' => '店铺名称',
|
||||
'online_order_no' => '线上订单号', 'task_remark' => '任务备注', 'seller_remark' => '卖家备注',
|
||||
'actual_paid_amount' => '实付金额', 'task_payment' => '任务付款金额', 'reimbursement_amount' => '最终报销金额',
|
||||
@ -80,7 +80,7 @@ class PlantingLossPageData
|
||||
$sync = $this->repository->syncStatus();
|
||||
$shippingSync = $this->calculator->shippingStatus();
|
||||
$empty = ['count' => 0, 'cost_missing' => 0] + array_fill_keys(self::SUM_KEYS, 0);
|
||||
$summary = ['total' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
|
||||
$summary = ['total' => 0, 'matched' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
|
||||
$items = [];
|
||||
$error = $this->syncError($sync);
|
||||
|
||||
@ -89,6 +89,7 @@ class PlantingLossPageData
|
||||
$all = $this->calculator->rows($filters['month']);
|
||||
foreach ($all as $row) {
|
||||
$summary['total']++;
|
||||
$summary['matched'] += ($row['shipped_at'] ?? '') !== '' ? 1 : 0;
|
||||
$this->add($summary[$row['loss_result']], $row);
|
||||
}
|
||||
foreach ($this->rows($filters, $all) as $row) {
|
||||
@ -100,7 +101,7 @@ class PlantingLossPageData
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
$error = '种菜损耗暂时无法计算(工单镜像或发货数据读取失败),请稍后重试。';
|
||||
$summary = ['total' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
|
||||
$summary = ['total' => 0, 'matched' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
|
||||
$items = [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,13 +5,14 @@ namespace Modules\EcommerceFinance\View;
|
||||
use App\Http\Controllers\Business\PayablesFinalActions;
|
||||
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnAccounting;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementStore;
|
||||
|
||||
/**
|
||||
* 「供应商对账」页面数据:
|
||||
* - 财务端数据:按供应商汇总 + 行级差异(条码 + 单价 匹配,四类结果带结算口径)
|
||||
* - 财务端数据:按供应商汇总 + 行级差异(采购单号 + 日期 + 条码匹配,四类结果带结算口径)
|
||||
* - 厂家端数据:厂家上传的对账单(实时)与只读的退货明细
|
||||
*/
|
||||
class SupplierReconciliationPageData
|
||||
@ -19,12 +20,19 @@ class SupplierReconciliationPageData
|
||||
/** 厂家上传的对账数据表:每页行数(短表 + 翻页)。 */
|
||||
private const PER_PAGE = 15;
|
||||
|
||||
/** 退货明细行较高:每页 10 行,并在页面内固定表头滚动查看。 */
|
||||
private const RETURN_PER_PAGE = 10;
|
||||
|
||||
/** 财务端比对明细:每页 15 条。 */
|
||||
private const COMPARISON_PER_PAGE = 15;
|
||||
|
||||
public function __construct(
|
||||
private readonly SupplierPayableOverrideStore $store,
|
||||
private readonly SupplierStatementStore $statements,
|
||||
private readonly SupplierStatementMatcher $matcher,
|
||||
private readonly SupplierReturnConfirmation $returnConfirmations,
|
||||
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
|
||||
private readonly SupplierReturnAccounting $returnAccounting,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -85,6 +93,15 @@ class SupplierReconciliationPageData
|
||||
foreach ($this->statements->uploadedSuppliers($companyId) as $name) {
|
||||
$suppliers[$name] = true;
|
||||
}
|
||||
foreach ($this->store->listRows(
|
||||
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
|
||||
$companyId,
|
||||
) as $row) {
|
||||
$name = trim((string) ($row['supplier'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$suppliers[$name] = true;
|
||||
}
|
||||
}
|
||||
$supplierNames = array_keys($suppliers);
|
||||
sort($supplierNames);
|
||||
|
||||
@ -95,17 +112,31 @@ class SupplierReconciliationPageData
|
||||
}
|
||||
|
||||
// 选中厂家时页面切换成「比对明细」视图(列表不再渲染,避免 100+ 家时往下翻)。
|
||||
$selected = trim((string) request()->query('supplier', ''));
|
||||
$supplierQuery = trim((string) request()->query('supplier', ''));
|
||||
$selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : '';
|
||||
$supplierNotFound = $supplierQuery !== '' && $selected === '';
|
||||
$detail = null;
|
||||
if ($selected !== '' && in_array($selected, $supplierNames, true)) {
|
||||
if ($selected !== '') {
|
||||
$detail = $this->detail($mine, $selected, $companyId);
|
||||
} else {
|
||||
$selected = '';
|
||||
$totalRows = count($detail['rows']);
|
||||
$pages = max((int) ceil($totalRows / self::COMPARISON_PER_PAGE), 1);
|
||||
$currentPage = min(max((int) request()->query('compare_page', 1), 1), $pages);
|
||||
$detail['total_rows'] = $totalRows;
|
||||
$detail['page'] = $currentPage;
|
||||
$detail['pages'] = $pages;
|
||||
$detail['per_page'] = self::COMPARISON_PER_PAGE;
|
||||
$detail['rows'] = array_slice(
|
||||
$detail['rows'],
|
||||
($currentPage - 1) * self::COMPARISON_PER_PAGE,
|
||||
self::COMPARISON_PER_PAGE,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.supplier-reconciliation.finance-data',
|
||||
'supplierNames' => $supplierNames,
|
||||
'supplierQuery' => $supplierQuery,
|
||||
'supplierNotFound' => $supplierNotFound,
|
||||
'uploads' => $uploads,
|
||||
'months' => $months,
|
||||
'month' => $month,
|
||||
@ -113,6 +144,9 @@ class SupplierReconciliationPageData
|
||||
'reconciler' => $reconciler,
|
||||
'selectedSupplier' => $selected,
|
||||
'detail' => $detail,
|
||||
'financeReturnPanel' => $detail !== null
|
||||
? $this->financeReturnPanel($selected, $companyId, $month, $reconciler)
|
||||
: null,
|
||||
'robot' => $selected !== '' ? $this->dingTalkNotifier->configuration($selected) : null,
|
||||
'listUrl' => route('business.pages.show', array_filter([
|
||||
'section' => 'supplier-reconciliation',
|
||||
@ -144,11 +178,22 @@ class SupplierReconciliationPageData
|
||||
foreach ($this->statements->uploadedSuppliers($companyId) as $name) {
|
||||
$suppliers[$name] = true;
|
||||
}
|
||||
foreach ($this->store->listRows(
|
||||
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
|
||||
$companyId,
|
||||
) as $row) {
|
||||
$name = trim((string) ($row['supplier'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$suppliers[$name] = true;
|
||||
}
|
||||
}
|
||||
$supplierNames = array_keys($suppliers);
|
||||
sort($supplierNames);
|
||||
|
||||
$selected = trim((string) request()->query('supplier', ''));
|
||||
if ($selected === '' || ! in_array($selected, $supplierNames, true)) {
|
||||
$supplierQuery = trim((string) request()->query('supplier', ''));
|
||||
$selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : '';
|
||||
$supplierNotFound = $supplierQuery !== '' && $selected === '';
|
||||
if ($supplierQuery === '') {
|
||||
$selected = $supplierNames[0] ?? '';
|
||||
}
|
||||
|
||||
@ -161,6 +206,8 @@ class SupplierReconciliationPageData
|
||||
return [
|
||||
'view' => 'business.pages.supplier-reconciliation.supplier-data',
|
||||
'supplierNames' => $supplierNames,
|
||||
'supplierQuery' => $supplierQuery,
|
||||
'supplierNotFound' => $supplierNotFound,
|
||||
'selectedSupplier' => $selected,
|
||||
'uploads' => $selected !== '' ? $this->statements->uploads($selected, $companyId) : [],
|
||||
'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE),
|
||||
@ -231,6 +278,46 @@ class SupplierReconciliationPageData
|
||||
return $this->detail($mine, $supplier, $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 财务端比对页的退货面板:与厂家端使用同一终版数据集,并保留月份/对账人筛选。
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function financeReturnPanel(
|
||||
string $supplier,
|
||||
int $companyId,
|
||||
string $month,
|
||||
string $reconciler,
|
||||
): array {
|
||||
$allRows = $this->returnRows($supplier, $companyId);
|
||||
$statuses = $this->returnConfirmations->statuses($supplier, $companyId);
|
||||
$rows = [];
|
||||
|
||||
foreach ($allRows as $row) {
|
||||
$rowMonth = substr(trim((string) ($row['send_time'] ?? '')), 0, 7);
|
||||
if ($rowMonth === '') {
|
||||
$rowMonth = substr(trim((string) ($row['summary_month'] ?? '')), 0, 7);
|
||||
}
|
||||
if ($month !== '' && $rowMonth !== $month) {
|
||||
continue;
|
||||
}
|
||||
if ($reconciler !== '' && trim((string) ($row['reconciler'] ?? '')) !== $reconciler) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row['_status'] = $statuses[(string) ($row['_action_key'] ?? '')] ?? '';
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return $this->returnConfirmationPanel(
|
||||
$supplier,
|
||||
$rows,
|
||||
$this->returnFilters([]),
|
||||
count($allRows),
|
||||
'finance_return_page',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $input
|
||||
* @return array{returnPanel:array<string,mixed>,returnWarehouses:list<string>}
|
||||
@ -268,36 +355,52 @@ class SupplierReconciliationPageData
|
||||
* @param array{keyword:string,date_from:string,date_to:string,warehouse:string,status:string} $filters
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function returnConfirmationPanel(string $supplier, array $returnRows, array $filters, int $allRows): array
|
||||
private function returnConfirmationPanel(
|
||||
string $supplier,
|
||||
array $returnRows,
|
||||
array $filters,
|
||||
int $allRows,
|
||||
string $pageQueryKey = 'return_page',
|
||||
): array
|
||||
{
|
||||
$total = 0.0;
|
||||
$confirmedAmount = 0.0;
|
||||
$disputedAmount = 0.0;
|
||||
$returnQuantity = 0.0;
|
||||
$confirmedQuantity = 0.0;
|
||||
$disputedQuantity = 0.0;
|
||||
$disputedCount = 0;
|
||||
$confirmedCount = 0;
|
||||
|
||||
$rows = [];
|
||||
foreach ($returnRows as $row) {
|
||||
$row = $this->returnAccounting->enrich($row);
|
||||
$key = (string) ($row['_action_key'] ?? '');
|
||||
$deduction = $this->returnDeduction($row);
|
||||
$deduction = $row['_accounting_target'] === '不记账'
|
||||
? 0.0
|
||||
: (float) $row['_supplier_amount'];
|
||||
$quantity = (float) $row['_supplier_qty'];
|
||||
$status = (string) ($row['_status'] ?? '');
|
||||
|
||||
$total += $deduction;
|
||||
$returnQuantity += $quantity;
|
||||
if ($status === SupplierReturnConfirmation::STATUS_CONFIRMED) {
|
||||
$confirmedAmount += $deduction;
|
||||
$confirmedQuantity += $quantity;
|
||||
$confirmedCount++;
|
||||
} elseif ($status === SupplierReturnConfirmation::STATUS_DISPUTED) {
|
||||
$disputedAmount += $deduction;
|
||||
$disputedQuantity += $quantity;
|
||||
$disputedCount++;
|
||||
}
|
||||
|
||||
$rows[] = ['_key' => $key, 'status' => $status, 'deduction' => $deduction] + $row;
|
||||
}
|
||||
|
||||
// 全部退货行按导入顺序分页展示(每页 15 行),不做金额排序。
|
||||
$perPage = self::PER_PAGE;
|
||||
// 全部退货行按导入顺序分页展示(每页 10 行),不做金额排序。
|
||||
$perPage = self::RETURN_PER_PAGE;
|
||||
$pages = max((int) ceil(count($rows) / $perPage), 1);
|
||||
$page = min(max((int) request()->query('return_page', 1), 1), $pages);
|
||||
$page = min(max((int) request()->query($pageQueryKey, 1), 1), $pages);
|
||||
|
||||
$filterQuery = $this->returnFilterQuery($filters);
|
||||
|
||||
@ -310,29 +413,22 @@ class SupplierReconciliationPageData
|
||||
'total_rows' => count($returnRows),
|
||||
'all_rows' => $allRows,
|
||||
'total_amount' => round($total, 2),
|
||||
'return_qty' => round($returnQuantity, 2),
|
||||
'confirmed_qty' => round($confirmedQuantity, 2),
|
||||
'disputed_qty' => round($disputedQuantity, 2),
|
||||
'pending_qty' => round(max($returnQuantity - $confirmedQuantity - $disputedQuantity, 0), 2),
|
||||
'pending_amount' => round(max($total - $confirmedAmount - $disputedAmount, 0), 2),
|
||||
'confirmed_count' => $confirmedCount,
|
||||
'confirmed_amount' => round($confirmedAmount, 2),
|
||||
'disputed_count' => $disputedCount,
|
||||
'disputed_amount' => round($disputedAmount, 2),
|
||||
'accounting_summary' => $this->returnAccounting->summarize($returnRows),
|
||||
'filters' => $filters,
|
||||
'filter_query' => $filterQuery,
|
||||
'is_filtered' => $filterQuery !== [],
|
||||
];
|
||||
}
|
||||
|
||||
/** 扣减金额:金额为空时用预估金额(与采购方记账口径一致)。 */
|
||||
private function returnDeduction(array $row): float
|
||||
{
|
||||
$amount = trim((string) ($row['amount'] ?? ''));
|
||||
if ($amount !== '' && is_numeric(str_replace(',', '', $amount))) {
|
||||
return (float) str_replace(',', '', $amount);
|
||||
}
|
||||
|
||||
$guess = trim((string) ($row['guess_amount'] ?? ''));
|
||||
|
||||
return is_numeric(str_replace(',', '', $guess)) ? (float) str_replace(',', '', $guess) : 0.0;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function detail(array $mine, string $supplier, int $companyId): array
|
||||
{
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
@endphp
|
||||
<div class="pw-page" data-preference-key="planting-loss:{{ company_id() }}:{{ optional(user())->id }}">
|
||||
<div class="pw-heading">
|
||||
<p>{{ $plantingFilters['month'] }} 月种菜报销明细(完成时间 {{ substr($plantingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subDay()->format('Y-m-d') }})的线上订单号,匹配 {{ $shippingLabel }} 发货数据(发货时间 {{ substr($plantingShippingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingShippingWindow['to'])->subDay()->format('Y-m-d') }})中的交易单号。匹配到的单中,任务备注或卖家备注写了“拍A发B / 拍A发A”的、以及只发了信誉订单虚拟条码/透明挂钩的不扣款,其余按发货商品成本价×1.2 扣款。</p>
|
||||
<p>{{ $plantingFilters['month'] }} 月种菜报销明细(完成时间 {{ substr($plantingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subDay()->format('Y-m-d') }})的线上订单号,匹配 {{ $shippingLabel }} 发货数据(发货时间 {{ substr($plantingShippingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingShippingWindow['to'])->subDay()->format('Y-m-d') }})中的交易单号。任务备注或卖家备注写了“拍A发B / 拍A发A”的(不论是否匹配到发货)、以及匹配到的单中只发了信誉订单虚拟条码/透明挂钩的不扣款,其余按发货商品成本价×数量+该单运费扣款。</p>
|
||||
<div class="pw-tags">
|
||||
<span class="pw-tag {{ $plantingSync['code'] === 'ready' ? '' : 'warn' }}">{{ $syncLabels[$plantingSync['code']] ?? $plantingSync['code'] }}@if($plantingSync['last_succeeded_at']) · {{ \Illuminate\Support\Str::of($plantingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span>
|
||||
<span class="pw-tag {{ ($plantingShippingSync['code'] ?? '') === 'succeeded' ? '' : 'warn' }}">{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}@if($plantingShippingSync['last_succeeded_at'] ?? null) · {{ \Illuminate\Support\Str::of($plantingShippingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span>
|
||||
@ -31,18 +31,18 @@
|
||||
<button type="submit" class="pw-primary">查询</button>
|
||||
<a class="pw-button" href="{{ request()->url() }}">重置</a>
|
||||
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-loss.export', $exportQuery) }}">导出当前筛选</a>
|
||||
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-loss.export', ['month' => $plantingFilters['month'], 'result' => Calculator::RESULT_EXEMPT]) }}">导出拍A发B(发运营)</a>
|
||||
<a class="pw-button" href="{{ route('business.planting-data.planting-loss.export', ['month' => $plantingFilters['month'], 'result' => Calculator::RESULT_EXEMPT, 'format' => 'reimbursement']) }}" title="按种菜报销字段导出当月全部拍A发B/拍A发A">导出拍A发B(发运营)</a>
|
||||
<details class="pw-column-settings"><summary class="pw-button">列设置</summary><div class="pw-column-menu"><p>勾选显示,拖动调整顺序</p><div data-pw-column-list>@foreach($columns as $key => $label)<label draggable="true" data-column-key="{{ $key }}"><input type="checkbox" value="{{ $key }}" checked> {{ $label }}</label>@endforeach</div><button type="button" class="pw-button" data-pw-reset>恢复默认</button><span data-pw-column-message role="status"></span></div></details>
|
||||
</form>
|
||||
@php $deduct = $plantingSummary[Calculator::RESULT_DEDUCT]; $exempt = $plantingSummary[Calculator::RESULT_EXEMPT]; $free = $plantingSummary[Calculator::RESULT_FREE_GOODS]; $unmatched = $plantingSummary[Calculator::RESULT_UNMATCHED]; @endphp
|
||||
<div class="pw-metrics">
|
||||
<div><span>当月种菜报销</span><strong>{{ number_format($plantingSummary['total']) }}</strong><small>匹配到发货 {{ number_format($deduct['count'] + $exempt['count'] + $free['count']) }} 单</small></div>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_DEDUCT) }}" class="is-key {{ $plantingFilters['result'] === Calculator::RESULT_DEDUCT ? 'is-active' : '' }}"><span>扣款金额(元)</span><strong>{{ $money($deduct['deduction_amount']) }}</strong><small>扣款 {{ number_format($deduct['count']) }} 单@if($deduct['cost_missing'] > 0) · <b class="pw-warn-text">{{ number_format($deduct['cost_missing']) }} 单成本价缺失</b>@endif</small></a>
|
||||
<div><span>当月种菜报销</span><strong>{{ number_format($plantingSummary['total']) }}</strong><small>匹配到发货 {{ number_format($plantingSummary['matched']) }} 单</small></div>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_DEDUCT) }}" class="is-key {{ $plantingFilters['result'] === Calculator::RESULT_DEDUCT ? 'is-active' : '' }}"><span>扣款金额(元)</span><strong>{{ $money($deduct['deduction_amount']) }}</strong><small>扣款 {{ number_format($deduct['count']) }} 单@if($deduct['cost_missing'] > 0) · <b class="pw-warn-text">{{ number_format($deduct['cost_missing']) }} 单成本价/运费缺失</b>@endif</small></a>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_EXEMPT) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_EXEMPT ? 'is-active' : '' }}"><span>不扣款(拍A发B/拍A发A)</span><strong>{{ number_format($exempt['count']) }}</strong><small>需发运营 · 最终报销 {{ $money($exempt['reimbursement_amount']) }}</small></a>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_FREE_GOODS) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_FREE_GOODS ? 'is-active' : '' }}"><span>不扣款(虚拟条码/透明挂钩)</span><strong>{{ number_format($free['count']) }}</strong><small>最终报销 {{ $money($free['reimbursement_amount']) }}</small></a>
|
||||
<a href="{{ $resultUrl(Calculator::RESULT_UNMATCHED) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_UNMATCHED ? 'is-active' : '' }}"><span>未匹配发货(不计入)</span><strong>{{ number_format($unmatched['count']) }}</strong><small>最终报销 {{ $money($unmatched['reimbursement_amount']) }}</small></a>
|
||||
</div>
|
||||
<details class="pw-rules"><summary>计算口径</summary><p>① 取该月种菜报销明细:工单类型“种菜报销”、状态“已完成”,完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59(与「种菜报销」页同一口径)。② 用线上订单号(交易单号)精确匹配「快递应付 → 发货数据」中发货时间在上月 1 日至本月末的交易单号,匹配到的保留,同一单多个包裹/商品合并为一行。③ 匹配到的单中,任务备注或卖家备注写了“拍A发B”或“拍A发A”(不区分全半角、大小写和空格)的不做扣款,单独列出发给运营。④ 实际只发了“信誉订单虚拟条码”“透明挂钩”这类商品的不扣款;同一单里这类商品也不计入扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 × 1.2(成本价 + 成本价×20%);普通商品资料里没有成本价(空或 0)的商品标为“成本价缺失”,不计入扣款金额,需补齐成本价后自动重算。未匹配到发货的单不计入种菜损耗,仅供核对。</p></details>
|
||||
<details class="pw-rules"><summary>计算口径</summary><p>① 取该月种菜报销明细:工单类型“种菜报销”、状态“已完成”,完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59(与「种菜报销」页同一口径)。② 用线上订单号(交易单号)精确匹配「快递应付 → 发货数据」中发货时间在上月 1 日至本月末的交易单号,匹配到的保留,同一单多个包裹/商品合并为一行。③ 任务备注或卖家备注写了“拍A发B”或“拍A发A”(不区分全半角、大小写和空格)的不做扣款,不论是否匹配到发货都单独列出,「导出拍A发B(发运营)」按种菜报销字段导出。④ 发货数据里该交易单号下出现“信誉订单虚拟条码”(X00006/X00002)或透明挂钩(条形码 GG 开头)的整单不扣款(同单号下的其它商品视为合并发货的别的订单);只发了“无实物”商品的也不扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 + 该订单在「发货数据」中的运费合计;普通商品资料里没有成本价(空或 0)的商品、发货数据里运费为空的订单分别标为“成本价缺失”“运费缺失”,缺失部分不计入扣款金额,补齐后自动重算。未匹配到发货的单不计入种菜损耗,仅供核对。</p></details>
|
||||
@if(($plantingShippingSync['code'] ?? '') !== 'succeeded' && ! $plantingError)<div class="pw-warn">发货数据当前状态为“{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}”{{ ($plantingShippingSync['warning'] ?? '') !== '' ? ':' . $plantingShippingSync['warning'] : '' }},匹配结果可能不完整。</div>@endif
|
||||
@if($plantingError)<div class="pw-error" role="alert">{{ $plantingError }}</div>@endif
|
||||
<section class="pw-panel" aria-label="种菜损耗明细">
|
||||
|
||||
@ -249,7 +249,7 @@
|
||||
<a class="settlement-button" href="{{ route('business.courier-surcharge.settlement.import-template') }}"><span class="material-icons-outlined" aria-hidden="true">download</span>下载导入模板</a>
|
||||
</div>
|
||||
<div class="settlement-import-meta" role="status"><span>当前筛选结果:<strong>{{ number_format((int) ($summary['count'] ?? $rows->total()), 0) }}</strong> 条</span><span>数据状态:{{ $syncLabel }}@if (! empty($sync['last_succeeded_at'])),最近同步 {{ $sync['last_succeeded_at'] }}@endif</span></div>
|
||||
<span class="settlement-table-hint">首行需包含「单号」「附加费类型」,建议提供「快递名称」;「金额」可选,填了会返回与系统的差异金额。</span>
|
||||
<span class="settlement-table-hint">首行只需「单号」「附加费类型」:系统会按单号自动匹配快递,并按启用的报价核算应结金额;「金额」可选,填了会返回与系统的差异金额。</span>
|
||||
</form>
|
||||
|
||||
<div class="settlement-table-surface" data-finance-table-surface>
|
||||
|
||||
@ -865,6 +865,7 @@
|
||||
<div><dt>原始范围</dt><dd data-quote-detail="range"></dd></div>
|
||||
<div><dt>执行日期</dt><dd data-quote-detail="date"></dd></div>
|
||||
<div><dt>公式单元格</dt><dd data-quote-detail="formulas"></dd></div>
|
||||
<div><dt>系统计费规则</dt><dd data-quote-detail="billing"></dd></div>
|
||||
</dl>
|
||||
<div class="quote-signed" data-quote-signed hidden>
|
||||
<span class="material-icons-outlined" aria-hidden="true">task_alt</span>
|
||||
@ -1180,6 +1181,7 @@
|
||||
setDetail('range', text(quote.source_range, '-'));
|
||||
setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注'));
|
||||
setDetail('formulas', String(quote.formula_count));
|
||||
setDetail('billing', text(quote.billing_summary, '未设置(运费需人工核算)'));
|
||||
var originals = root.querySelector('[data-stock-original-links]');
|
||||
originals.replaceChildren();
|
||||
(quote.stock_originals || []).forEach(function (file, index, files) {
|
||||
|
||||
@ -92,14 +92,6 @@
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<label class="product-cost-check">
|
||||
<input type="checkbox" name="missing_price" value="1" @checked(($filters['missing_price'] ?? '') === '1')>
|
||||
只看未匹配商品单价
|
||||
</label>
|
||||
<label class="product-cost-check">
|
||||
<input type="checkbox" name="include_excluded" value="1" @checked(($filters['include_excluded'] ?? '') === '1')>
|
||||
包含「-不看」仓
|
||||
</label>
|
||||
<div class="product-cost-actions">
|
||||
<button class="product-cost-button primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
|
||||
<a class="product-cost-button" href="{{ route('business.financial-operations.product-costs.export', array_merge(['company_id' => company_id()], request()->except('page'))) }}" aria-label="导出当前筛选结果" title="导出当前筛选结果"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</a>
|
||||
@ -113,14 +105,18 @@
|
||||
|
||||
<div class="product-cost-note">
|
||||
口径:以「业绩明细」的账单行为基数(发生时间 / 店铺名称 / 线上单号),按「店铺 + 线上订单号」展开订单侧商品行,
|
||||
只取收入金额有数据且不为 0 的账单行,一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称,仓库简称查仓库映射表,
|
||||
商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价,匹配不到时退回「线上代发价格」,
|
||||
商品成本 = 商品数量 × 商品单价。
|
||||
拆单子单重复列出的同一批商品只算一次,同一单分多仓发货的按子单实际仓库分行;虚拟条码(X0000x)不计入。
|
||||
只取收入金额有数据且不为 0 的账单行,「种菜金额」有数据(>0)的账单行整条排除、不参与匹配;
|
||||
一行 = 一个发货仓库的一个商品;发货仓库取订单的聚水潭仓库名称,
|
||||
仓库简称按仓库映射表用「仓储方」匹配「仓库名含主仓」的那条取简称(没有主仓行时认同名仓库,
|
||||
常州简尚家居有限公司对应映射表里的「配件单仓」行),
|
||||
商品单价按「仓库简称 + 商品编码」取「商品月加权平均单价」里的加权平均价;匹配不到时退回「线上代发价格」
|
||||
(采购单价优先、其次钉钉代发单价),仍匹配不到时用「普通商品资料」里的商品成本价兜底;商品成本 = 商品数量 × 商品单价。
|
||||
拆单子单重复列出的同一批商品只算一次,同一单分多仓发货的按子单实际仓库分行;虚拟条码(X0000x)不计入;
|
||||
套餐/服务占位编码 TCXJ、FUWU 不取数(组合装里拆出的这两个编码同样剔除)。
|
||||
定制配件这类明细行,聚水潭把配件件数写进了「商品数量」(例如数量 9524、金额 2000.04,实际单价 0.21),
|
||||
乘成品的月加权/代发单价会放大几百倍;成本单价高于该行明细单价 3 倍时判为「数量与单价口径不一致」,成本留空、不计入合计。
|
||||
仓库映射表里标记为「-不看」的仓库(次品 / 销退 / 虚拟 / 信誉订单 / 京东云仓等)默认不展示,
|
||||
需要核对时可以勾选「包含『-不看』仓」。
|
||||
页面默认展示全部数据:已匹配/未匹配商品单价的行、仓库映射表里标记为「-不看」的仓
|
||||
(次品 / 销退 / 虚拟 / 信誉订单 / 京东云仓等)都纳入列表与统计。
|
||||
</div>
|
||||
|
||||
<div class="product-cost-summary">
|
||||
@ -130,9 +126,9 @@
|
||||
<div class="item {{ ($summary['missing_price_rows'] ?? 0) > 0 ? 'is-warn' : '' }}"><span>未匹配商品单价行数</span><b>{{ number_format((int) ($summary['missing_price_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>未匹配单价的商品数量</span><b>{{ $qty($summary['missing_price_quantity'] ?? null) }}</b></div>
|
||||
<div class="item"><span>其中用线上代发价格兜底</span><b>{{ number_format((int) ($summary['fallback_price_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>其中用商品资料成本价兜底</span><b>{{ number_format((int) ($summary['catalog_price_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>数量与单价口径不一致(不计成本)</span><b>{{ number_format((int) ($summary['unit_mismatch_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item {{ ($summary['missing_warehouse_rows'] ?? 0) > 0 ? 'is-warn' : '' }}"><span>未匹配仓库简称行数</span><b>{{ number_format((int) ($summary['missing_warehouse_rows'] ?? 0)) }}</b></div>
|
||||
<div class="item"><span>已隐藏的「-不看」仓行数</span><b>{{ number_format((int) ($summary['excluded_rows'] ?? 0)) }}</b></div>
|
||||
</div>
|
||||
|
||||
<div class="product-cost-table-wrap">
|
||||
|
||||
@ -739,6 +739,86 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 业绩明细:把长口径说明拆成可扫描的规则分组,完整原文默认折叠 */
|
||||
.receipt-method-guide {
|
||||
margin: 0 0 1rem;
|
||||
padding: .9rem 1rem;
|
||||
border: 1px solid var(--vip-border, #d8d2c7);
|
||||
border-radius: .75rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.receipt-method-guide__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: .7rem 1.5rem;
|
||||
}
|
||||
|
||||
.receipt-method-guide__section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.receipt-method-guide__section h3 {
|
||||
margin: 0 0 .3rem;
|
||||
color: var(--vip-text, #171717);
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.receipt-method-guide__section ul {
|
||||
display: grid;
|
||||
gap: .12rem;
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
color: var(--vip-text-muted, #595959);
|
||||
font-size: .75rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.receipt-method-guide__formula {
|
||||
color: var(--vip-text, #171717);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.receipt-method-guide__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .3rem;
|
||||
}
|
||||
|
||||
.receipt-method-guide__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: .08rem .4rem;
|
||||
border: 1px solid #e8dfcd;
|
||||
border-radius: .3rem;
|
||||
background: #fffdf8;
|
||||
color: var(--vip-text-muted, #595959);
|
||||
font-size: .6875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.receipt-method-guide__details {
|
||||
margin-top: .7rem;
|
||||
padding-top: .65rem;
|
||||
border-top: 1px solid #eee7da;
|
||||
}
|
||||
|
||||
.receipt-method-guide__details summary {
|
||||
color: var(--vip-content-accent-dark, #856632);
|
||||
cursor: pointer;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.receipt-method-guide__details p {
|
||||
margin: .55rem 0 0;
|
||||
color: var(--vip-text-muted, #595959);
|
||||
font-size: .75rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* 业绩明细:店铺收款匹配统计条 */
|
||||
.receipt-match-summary {
|
||||
display: flex;
|
||||
@ -774,6 +854,18 @@
|
||||
gap: .35rem 1.25rem;
|
||||
}
|
||||
|
||||
.receipt-match-summary__items--amounts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: .4rem 1.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.receipt-match-summary__items--amounts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.receipt-match-summary__item--lead strong {
|
||||
font-size: 1.25rem;
|
||||
color: #b80000;
|
||||
@ -797,6 +889,25 @@
|
||||
color: var(--vip-text, #1f2937);
|
||||
}
|
||||
|
||||
.receipt-match-summary__item--amount {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.receipt-match-summary__item--amount strong {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.receipt-match-summary__value-unit {
|
||||
margin-left: .15em;
|
||||
color: #8a8377;
|
||||
font-size: .75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.receipt-match-summary__item.is-ok strong {
|
||||
color: var(--vip-success, #137a3c);
|
||||
}
|
||||
@ -1076,7 +1187,7 @@
|
||||
<span class="material-icons-outlined" aria-hidden="true">{{ $directory['icon'] ?? 'table_view' }}</span>
|
||||
<div>
|
||||
<strong>{{ $page['title'] ?? $section['title'] }} · 字段结构</strong>
|
||||
@if (!empty($directory['description']))
|
||||
@if (!empty($directory['description']) && ($page['slug'] ?? '') !== 'performance-details')
|
||||
<p class="directory-table-description">{{ $directory['description'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@ -1118,6 +1229,57 @@
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if (($page['slug'] ?? '') === 'performance-details' && !empty($directory['description']))
|
||||
<section class="receipt-method-guide" aria-label="业绩明细指标口径">
|
||||
<div class="receipt-method-guide__grid">
|
||||
<div class="receipt-method-guide__section">
|
||||
<h3>匹配口径</h3>
|
||||
<ul>
|
||||
<li>一行 = 一个店铺 + 一个线上订单号</li>
|
||||
<li>商品金额:主单 → 原单行 → 全部行</li>
|
||||
<li>合单/拆单只挂一次,排除 X0000x 虚拟条码</li>
|
||||
<li>订单实付取订单级实付,再扣虚拟条码行实付</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="receipt-method-guide__section">
|
||||
<h3>金额字段</h3>
|
||||
<ul>
|
||||
<li>收入:总账备注 = 订单收入</li>
|
||||
<li>优惠/冲减:分别取优惠收入、冲减收入,不计入收入</li>
|
||||
<li>退款:单独列示,不参与收入和差额</li>
|
||||
<li>种菜:种菜订单的收入子集,不参与差额</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="receipt-method-guide__section">
|
||||
<h3>差额判定</h3>
|
||||
<ul>
|
||||
<li><span class="receipt-method-guide__formula">差额 = 收入 + 优惠 + 冲减 − 订单实付</span></li>
|
||||
<li>未匹配时按订单实付 = 0 计入差额</li>
|
||||
<li>原因按顺序命中;无明确原因时标「差额待核实」</li>
|
||||
<li>卖家实收 / 应付已付能对平的行,差额按 0 计</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="receipt-method-guide__section">
|
||||
<h3>匹配原因</h3>
|
||||
<div class="receipt-method-guide__chips">
|
||||
<span class="receipt-method-guide__chip">账单无单号</span>
|
||||
<span class="receipt-method-guide__chip">单号格式异常</span>
|
||||
<span class="receipt-method-guide__chip">订单表无此单</span>
|
||||
<span class="receipt-method-guide__chip">订单无实付金额</span>
|
||||
<span class="receipt-method-guide__chip">无收款账单</span>
|
||||
<span class="receipt-method-guide__chip">1688 采销收款</span>
|
||||
<span class="receipt-method-guide__chip">店铺名不一致</span>
|
||||
<span class="receipt-method-guide__chip">同单多笔收款</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="receipt-method-guide__details">
|
||||
<summary>查看完整字段与差额判定规则</summary>
|
||||
<p>{{ $directory['description'] }}</p>
|
||||
</details>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if (($page['slug'] ?? '') === 'performance-details' && !empty($receiptStats))
|
||||
{{-- 匹配统计:账单笔数、成功/未匹配笔数、双方收入合计核对结果 --}}
|
||||
<div class="receipt-match-summary" role="status" aria-label="店铺收款匹配统计">
|
||||
@ -1134,15 +1296,15 @@
|
||||
</div>
|
||||
<div class="receipt-match-summary__group">
|
||||
<div class="receipt-match-summary__title">金额构成</div>
|
||||
<div class="receipt-match-summary__items">
|
||||
<span class="receipt-match-summary__item"><em>账单收入合计</em><strong>{{ number_format((float) $receiptStats['income_total'], 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item"><em>优惠金额合计</em><strong>{{ number_format((float) ($receiptStats['discount_total'] ?? 0), 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item"><em>冲减金额合计</em><strong>{{ number_format((float) ($receiptStats['offset_total'] ?? 0), 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item"><em>退款金额合计</em><strong>{{ number_format((float) ($receiptStats['refund_total'] ?? 0), 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item"><em>种菜金额合计</em><strong>{{ number_format((float) ($receiptStats['planting_total'] ?? 0), 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item"><em>订单商品金额合计</em><strong>{{ number_format((float) $receiptStats['goods_total'], 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item"><em>订单实付金额合计</em><strong>{{ number_format((float) ($receiptStats['paid_total'] ?? 0), 2) }}</strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--lead"><em>差额合计</em><strong>{{ number_format((float) $receiptStats['difference_total'], 2) }}</strong></span>
|
||||
<div class="receipt-match-summary__items receipt-match-summary__items--amounts">
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>账单收入合计</em><strong>{{ number_format((float) $receiptStats['income_total'] / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>订单商品金额合计</em><strong>{{ number_format((float) $receiptStats['goods_total'] / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>订单实付金额合计</em><strong>{{ number_format((float) ($receiptStats['paid_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>优惠金额合计</em><strong>{{ number_format((float) ($receiptStats['discount_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>冲减金额合计</em><strong>{{ number_format((float) ($receiptStats['offset_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>退款金额合计</em><strong>{{ number_format((float) ($receiptStats['refund_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount"><em>种菜金额合计</em><strong>{{ number_format((float) ($receiptStats['planting_total'] ?? 0) / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
<span class="receipt-match-summary__item receipt-match-summary__item--amount receipt-match-summary__item--lead"><em>差额合计</em><strong>{{ number_format((float) $receiptStats['difference_total'] / 10000, 2) }}<span class="receipt-match-summary__value-unit">万</span></strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1734,13 +1896,17 @@
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
widgets.forEach(function (widget) { widget.setLoading(true); });
|
||||
window.fetch(endpoint + '?' + currentParams().toString(), {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
return response.ok ? response.json() : null;
|
||||
}).then(function (data) {
|
||||
if (!data || !data.facets) { return; }
|
||||
if (!data || !data.facets) {
|
||||
widgets.forEach(function (widget) { widget.setLoading(false); });
|
||||
return;
|
||||
}
|
||||
widgets.forEach(function (widget) {
|
||||
var facet = data.facets[widget.root.getAttribute('data-name')];
|
||||
if (!facet || !facet.values) { return; }
|
||||
@ -1750,8 +1916,10 @@
|
||||
});
|
||||
widget.setOptions(options, facet.counts || {});
|
||||
});
|
||||
widgets.forEach(function (widget) { widget.setLoading(false); });
|
||||
}).catch(function () {
|
||||
// 联动统计失败时保留原有选项,不影响筛选本身。
|
||||
widgets.forEach(function (widget) { widget.setLoading(false); });
|
||||
});
|
||||
}
|
||||
|
||||
@ -1780,7 +1948,8 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
schedule();
|
||||
// 首次加载立即取联动数量;只有勾选变化时才做 250ms 防抖。
|
||||
refresh();
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
|
||||
@ -119,6 +119,7 @@
|
||||
<a href="{{ $finalRows->previousPageUrl() }}">上一页</a>
|
||||
@endif
|
||||
第 {{ $finalRows?->currentPage() ?? 1 }} 页 / 共 {{ $finalRows?->lastPage() ?? 1 }} 页
|
||||
· 每页 {{ $finalRows?->perPage() ?? (in_array($finalDataset, ['payment-reconciliation', 'return-reconciliation'], true) ? 15 : 50) }} 条
|
||||
@if ($finalRows?->hasMorePages())
|
||||
<a href="{{ $finalRows->nextPageUrl() }}">下一页</a>
|
||||
@endif
|
||||
|
||||
@ -149,6 +149,7 @@
|
||||
var valuesEl = root.querySelector('[data-role="values"]');
|
||||
var candidates = (opts.options || []).slice();
|
||||
var counts = opts.counts || {};
|
||||
var countsLoading = false;
|
||||
var selectedList = [];
|
||||
var keyword = '';
|
||||
var optsEl = null;
|
||||
@ -163,7 +164,9 @@
|
||||
function isSel(v) { return selectedList.indexOf(v) >= 0; }
|
||||
function countText(v) {
|
||||
var total = counts[v];
|
||||
if (total === null || total === undefined) { return ''; }
|
||||
if (total === null || total === undefined) {
|
||||
return countsLoading ? '统计中…' : '';
|
||||
}
|
||||
|
||||
return String(total).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
@ -243,8 +246,13 @@
|
||||
setOptions: function (list, nextCounts) {
|
||||
candidates = (list || []).slice();
|
||||
if (nextCounts) { counts = nextCounts; }
|
||||
countsLoading = false;
|
||||
renderOptions();
|
||||
sync();
|
||||
},
|
||||
setLoading: function (loading) {
|
||||
countsLoading = Boolean(loading);
|
||||
renderOptions();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -778,7 +778,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (!($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false) && !$productCatalogError)
|
||||
@if ((!($productCatalogJushuitanCostPriceHistorySyncStatus['initialized'] ?? false) || ($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'failed') && !$productCatalogError)
|
||||
<div
|
||||
class="product-catalog-notice"
|
||||
role="status"
|
||||
@ -786,7 +786,9 @@
|
||||
>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">schedule</span>
|
||||
<span>
|
||||
@if (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initializing')
|
||||
@if (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'failed')
|
||||
聚水潭成本价历史最近同步失败,记录可能不完整;镜像价格观测单独展示,不代表完整历史。
|
||||
@elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initializing')
|
||||
聚水潭成本价记录正在执行首次同步,完成前不显示记录数量。
|
||||
@elseif (($productCatalogJushuitanCostPriceHistorySyncStatus['code'] ?? '') === 'initial_failed')
|
||||
聚水潭成本价记录首次同步失败,当前记录数量不代表聚水潭真实历史。
|
||||
@ -905,6 +907,14 @@
|
||||
<span class="product-catalog-history-count">{{ $jushuitanHistoryInitialized ? $jushuitanHistoryCount . ' 条' : '待同步' }}</span>
|
||||
<script id="{{ $jushuitanHistoryId }}" type="application/json">@json($jushuitanHistory, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)</script>
|
||||
</div>
|
||||
@php($observations = $row->jushuitan_cost_price_observations ?? [])
|
||||
<div class="product-catalog-history-action">
|
||||
<button type="button" class="product-catalog-icon-button" data-price-history-trigger data-history-target="{{ $jushuitanHistoryId }}-observations" data-history-source="镜像价格观测(发现时间,非真实改价时间)" data-sku-code="{{ $row->sku_id ?? '' }}" data-product-name="{{ $row->name ?? '' }}" data-cost-price="{{ $row->cost_price ?? '' }}" data-work-platform-price="{{ $row->work_platform_price ?? '' }}" aria-label="查看镜像价格观测" title="仅从建立基线后记录价格变化,最多展示最近20条,不代表完整操作历史" @disabled($observations === [])>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">visibility</span>
|
||||
</button>
|
||||
<span class="product-catalog-history-count">观测 {{ count($observations) }} 条(最近20条)</span>
|
||||
<script id="{{ $jushuitanHistoryId }}-observations" type="application/json">@json($observations, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)</script>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="product-catalog-history-action">
|
||||
|
||||
@ -27,12 +27,18 @@
|
||||
.supplier-reconciliation-page .sr-tag.is-extra { background: #FDF1E2; color: #B4650A; }
|
||||
.supplier-reconciliation-page .sr-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
|
||||
.supplier-reconciliation-page .sr-tag.is-missing { background: #EAF2F9; color: #2F6FA8; }
|
||||
.supplier-reconciliation-page .sr-tag.is-logistics { background: #F7F0DE; color: #856632; }
|
||||
.supplier-reconciliation-page .sr-tag.is-no-bookkeeping { background: #EFEAE0; color: #595959; }
|
||||
.supplier-reconciliation-page tr.is-extra td,
|
||||
.supplier-reconciliation-page tr.is-mismatch td { background: #FFFDF7; }
|
||||
.supplier-reconciliation-page tr.is-logistics td { background: #FFFDF7; }
|
||||
.supplier-reconciliation-page .sr-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
|
||||
.supplier-reconciliation-page .sr-summary-cell { padding: 10px 12px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; display: grid; gap: 4px; }
|
||||
.supplier-reconciliation-page .sr-summary-cell b { font-size: 1.125rem; font-weight: 600; }
|
||||
.supplier-reconciliation-page .sr-summary-cell span { color: var(--vip-content-muted, #595959); font-size: .75rem; }
|
||||
.supplier-reconciliation-page .sr-comparison-scroll { max-height: min(62vh, 560px); overflow: auto; scrollbar-gutter: stable; }
|
||||
.supplier-reconciliation-page .sr-comparison-scroll thead th { position: sticky; top: 0; z-index: 4; box-shadow: 0 1px 0 var(--vip-border, #D8D2C7); }
|
||||
.supplier-reconciliation-page .sr-pagination { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; }
|
||||
.supplier-reconciliation-page .sr-robot-config { padding: 10px 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; }
|
||||
.supplier-reconciliation-page .sr-robot-config summary { cursor: pointer; color: #5B5343; font-size: .8125rem; font-weight: 600; }
|
||||
.supplier-reconciliation-page .sr-robot-form { display: grid; grid-template-columns: minmax(260px, 2fr) minmax(180px, 1fr) auto; gap: 10px; align-items: end; margin-top: 12px; }
|
||||
@ -79,12 +85,17 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="sr-field"><span>供应商</span>
|
||||
<select name="supplier" onchange="this.form.submit()">
|
||||
<option value="">请选择厂家</option>
|
||||
<input type="search"
|
||||
name="supplier"
|
||||
value="{{ $supplierQuery !== '' ? $supplierQuery : $selectedSupplier }}"
|
||||
list="finance-supplier-options"
|
||||
placeholder="输入厂家名称搜索"
|
||||
autocomplete="off">
|
||||
<datalist id="finance-supplier-options">
|
||||
@foreach ($supplierNames as $supplier)
|
||||
<option value="{{ $supplier }}" @selected($selectedSupplier === $supplier)>{{ $supplier }}</option>
|
||||
<option value="{{ $supplier }}"></option>
|
||||
@endforeach
|
||||
</select>
|
||||
</datalist>
|
||||
</label>
|
||||
<button type="submit" class="sr-button is-primary">查询</button>
|
||||
</div>
|
||||
@ -94,10 +105,16 @@
|
||||
<a href="{{ route('vendors.index') }}" class="sr-button">厂家登录账号管理</a>
|
||||
@endcanany
|
||||
</form>
|
||||
@if ($supplierNotFound)
|
||||
<div class="biz-alert biz-alert-error" role="alert">
|
||||
未找到名为「{{ $supplierQuery }}」的供应商,请输入完整名称并从搜索建议中选择。
|
||||
</div>
|
||||
@endif
|
||||
<p class="sr-hint">
|
||||
比对口径:按「条码 + 单价」逐行核对(同一把钥匙的多行先合并数量与金额)。一致 → 正常结算;
|
||||
厂家多(我方没有)→ 不结算、退回厂家修改账单;金额/数量不一致 → 取金额低的一方结算;
|
||||
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家上传的对账单在「厂家端数据」实时可查。<br>
|
||||
比对口径:按「采购单号 + 日期 + 商品编码」核对,以我方货款对账的“商品编码”匹配厂家条码;同一采购单号、日期和商品编码的多行先合并数量与金额。
|
||||
数量、金额一致 → 正常结算;厂家多(我方没有)→ 不结算、退回修改;
|
||||
数量或金额不一致 → 明确列出双方数量、单价、金额及厂家多算部分,取金额低的一方结算;
|
||||
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家未填采购单号时,仅在同日期+商品编码能唯一匹配我方一条数据时兜底。<br>
|
||||
厂家登录:在「厂家登录账号管理」新增供应商并勾选可登录,供应商名称必须与对账数据中的厂家名称完全一致;
|
||||
厂家登录后只能查看自己的对账单上传记录、全部退货明细及导出数据。
|
||||
</p>
|
||||
@ -123,6 +140,12 @@
|
||||
</span>
|
||||
<span style="flex:1 1 auto"></span>
|
||||
<a href="{{ $listUrl }}" class="sr-button">← 返回供应商列表</a>
|
||||
<a href="{{ route('business.supplier-reconciliation.comparison.export', array_filter([
|
||||
'supplier' => $selectedSupplier,
|
||||
'month' => $month,
|
||||
'reconciler' => $reconciler,
|
||||
], static fn (mixed $value): bool => $value !== '' && $value !== null)) }}"
|
||||
class="sr-button">导出比对汇总</a>
|
||||
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
|
||||
@if ($differenceCount > 0)
|
||||
<form method="POST"
|
||||
@ -239,13 +262,15 @@
|
||||
<div class="sr-summary">
|
||||
<div class="sr-summary-cell"><span>双方一致</span><b>{{ $detail['summary']['match'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家多(退回修改)</span><b>{{ $detail['summary']['supplierExtra'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>金额不一致(按低结算)</span><b>{{ $detail['summary']['mismatch'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>数量/金额不一致(按低结算)</span><b>{{ $detail['summary']['mismatch'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家少(按厂家结算)</span><b>{{ $detail['summary']['supplierMissing'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>我方金额合计</span><b>{{ number_format($detail['summary']['mineAmount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家金额合计</span><b>{{ number_format($detail['summary']['theirsAmount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家多算金额</span><b>{{ number_format($detail['summary']['supplierOverAmount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>按口径结算合计</span><b>{{ number_format($detail['summary']['settleAmount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>差异金额</span><b>{{ number_format($detail['summary']['differenceAmount'], 2) }}</b></div>
|
||||
</div>
|
||||
<div class="sr-scroll">
|
||||
<div class="sr-scroll sr-comparison-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -253,12 +278,17 @@
|
||||
<input id="select-all-differences" type="checkbox" aria-label="选择全部差异">
|
||||
</th>
|
||||
<th scope="col">比对结果</th>
|
||||
<th scope="col">条码</th>
|
||||
<th scope="col">单价</th>
|
||||
<th scope="col">采购单号</th>
|
||||
<th scope="col">日期</th>
|
||||
<th scope="col">我方商品编码 / 厂家条码</th>
|
||||
<th scope="col">我方数量</th>
|
||||
<th scope="col">厂家数量</th>
|
||||
<th scope="col">我方单价</th>
|
||||
<th scope="col">我方金额</th>
|
||||
<th scope="col">厂家数量</th>
|
||||
<th scope="col">厂家单价</th>
|
||||
<th scope="col">厂家金额</th>
|
||||
<th scope="col">数量差异(厂家-我方)</th>
|
||||
<th scope="col">金额差异(厂家-我方)</th>
|
||||
<th scope="col">结算口径</th>
|
||||
<th scope="col">结算金额</th>
|
||||
<th scope="col">推送</th>
|
||||
@ -269,7 +299,7 @@
|
||||
@php
|
||||
$tagClass = match ($row['type']) {
|
||||
'厂家多' => 'is-extra',
|
||||
'金额不一致' => 'is-mismatch',
|
||||
'数量/金额不一致' => 'is-mismatch',
|
||||
'厂家少' => 'is-missing',
|
||||
default => 'is-match',
|
||||
};
|
||||
@ -281,24 +311,41 @@
|
||||
form="push-selected-form"
|
||||
type="checkbox"
|
||||
name="selected_rows[]"
|
||||
value="{{ json_encode(['barcode' => $row['barcode'], 'type' => $row['type'], 'unitPrice' => $row['unitPrice']], JSON_UNESCAPED_UNICODE) }}"
|
||||
value="{{ json_encode(['purchaseNo' => $row['purchaseNo'], 'date' => $row['date'], 'barcode' => $row['barcode'], 'type' => $row['type']], JSON_UNESCAPED_UNICODE) }}"
|
||||
aria-label="选择差异 {{ $row['barcode'] }}">
|
||||
@else
|
||||
<span class="sr-muted">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td><span class="sr-tag {{ $tagClass }}">{{ $row['type'] }}</span></td>
|
||||
<td>{{ $row['purchaseNo'] !== '' ? $row['purchaseNo'] : '—' }}</td>
|
||||
<td>{{ $row['date'] !== '' ? $row['date'] : '—' }}</td>
|
||||
<td>
|
||||
{{ $row['barcode'] }}
|
||||
@if (! empty($row['supplierBarcode']))
|
||||
<span class="sr-muted" style="font-size:.7rem">(厂家:{{ $row['supplierBarcode'] }})</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">{{ number_format($row['unitPrice'], 2) }}</td>
|
||||
<td class="is-num">{{ $row['mineQty'] !== 0.0 ? number_format($row['mineQty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['mineUnitPrice'] !== 0.0 ? number_format($row['mineUnitPrice'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['mineAmount'] !== 0.0 ? number_format($row['mineAmount'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsUnitPrice'] !== 0.0 ? number_format($row['theirsUnitPrice'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsAmount'] !== 0.0 ? number_format($row['theirsAmount'], 2) : '—' }}</td>
|
||||
<td class="is-num">
|
||||
@if (abs($row['qtyDifference']) < 0.005)
|
||||
一致
|
||||
@else
|
||||
厂家{{ $row['qtyDifference'] > 0 ? '多' : '少' }} {{ number_format(abs($row['qtyDifference']), 2) }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">
|
||||
@if (abs($row['amountDifference']) < 0.005)
|
||||
一致
|
||||
@else
|
||||
厂家{{ $row['amountDifference'] > 0 ? '多' : '少' }} ¥{{ number_format(abs($row['amountDifference']), 2) }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="sr-muted">{{ $row['note'] }}</td>
|
||||
<td class="is-num">{{ number_format($row['settleAmount'], 2) }}</td>
|
||||
<td>
|
||||
@ -311,9 +358,10 @@
|
||||
<input type="hidden" name="month" value="{{ $month }}">
|
||||
<input type="hidden" name="reconciler" value="{{ $reconciler }}">
|
||||
<input type="hidden" name="scope" value="row">
|
||||
<input type="hidden" name="purchase_no" value="{{ $row['purchaseNo'] }}">
|
||||
<input type="hidden" name="date" value="{{ $row['date'] }}">
|
||||
<input type="hidden" name="barcode" value="{{ $row['barcode'] }}">
|
||||
<input type="hidden" name="difference_type" value="{{ $row['type'] }}">
|
||||
<input type="hidden" name="unit_price" value="{{ number_format($row['unitPrice'], 4, '.', '') }}">
|
||||
<button type="submit"
|
||||
class="sr-button is-small {{ $robot['configured'] ? 'is-primary' : 'is-disabled' }}"
|
||||
@disabled(! $robot['configured'])
|
||||
@ -327,11 +375,152 @@
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="11"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
|
||||
<tr><td colspan="16"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="sr-pagination">
|
||||
@php
|
||||
$comparisonPageCount = max((int) ($detail['pages'] ?? 1), 1);
|
||||
$comparisonPageCurrent = min(max((int) ($detail['page'] ?? 1), 1), $comparisonPageCount);
|
||||
$comparisonPageStart = max(1, min($comparisonPageCurrent - 4, $comparisonPageCount - 8));
|
||||
$comparisonPageEnd = min($comparisonPageCount, $comparisonPageStart + 8);
|
||||
$comparisonPageUrl = fn (int $number): string => route('business.pages.show', array_merge(request()->query(), [
|
||||
'section' => $section['slug'],
|
||||
'page' => $page['slug'],
|
||||
'compare_page' => $number,
|
||||
]));
|
||||
@endphp
|
||||
<span>
|
||||
共 {{ number_format((int) ($detail['total_rows'] ?? 0)) }} 条
|
||||
· 每页 {{ number_format((int) ($detail['per_page'] ?? 15)) }} 条
|
||||
· 第 {{ $comparisonPageCurrent }} / {{ $comparisonPageCount }} 页
|
||||
</span>
|
||||
@if ($comparisonPageCurrent > 1)
|
||||
<a class="sr-button is-small" href="{{ $comparisonPageUrl($comparisonPageCurrent - 1) }}">上一页</a>
|
||||
@endif
|
||||
@if ($comparisonPageStart > 1)
|
||||
<a class="sr-button is-small" href="{{ $comparisonPageUrl(1) }}">1</a>
|
||||
@if ($comparisonPageStart > 2) <span>…</span> @endif
|
||||
@endif
|
||||
@for ($number = $comparisonPageStart; $number <= $comparisonPageEnd; $number++)
|
||||
<a class="sr-button is-small {{ $number === $comparisonPageCurrent ? 'is-primary' : '' }}"
|
||||
@if ($number === $comparisonPageCurrent) aria-current="page" @endif
|
||||
href="{{ $comparisonPageUrl($number) }}">{{ $number }}</a>
|
||||
@endfor
|
||||
@if ($comparisonPageEnd < $comparisonPageCount)
|
||||
@if ($comparisonPageEnd < $comparisonPageCount - 1) <span>…</span> @endif
|
||||
<a class="sr-button is-small" href="{{ $comparisonPageUrl($comparisonPageCount) }}">{{ $comparisonPageCount }}</a>
|
||||
@endif
|
||||
@if ($comparisonPageCurrent < $comparisonPageCount)
|
||||
<a class="sr-button is-small" href="{{ $comparisonPageUrl($comparisonPageCurrent + 1) }}">下一页</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php $returnAccounting = $financeReturnPanel['accounting_summary']; @endphp
|
||||
<div class="sr-card">
|
||||
<div class="sr-toolbar">
|
||||
<h2>退货扣减 · 财务与厂家同步</h2>
|
||||
<span class="sr-muted" style="font-size:.75rem">
|
||||
直接读取「应付款终版 → 退货对账」同一数据集;修改退货数量、备注、标题后,财务端和厂家门户同时更新。
|
||||
</span>
|
||||
</div>
|
||||
<div class="sr-summary">
|
||||
<div class="sr-summary-cell"><span>退货行数</span><b>{{ number_format($returnAccounting['count']) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家净扣减</span><b>¥{{ number_format($returnAccounting['supplier_amount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>转物流记账</span><b>{{ number_format($returnAccounting['logistics_qty'], 2) }} 件</b></div>
|
||||
<div class="sr-summary-cell"><span>转物流金额</span><b>¥{{ number_format($returnAccounting['logistics_amount'], 2) }}</b></div>
|
||||
<div class="sr-summary-cell"><span>不记账行数</span><b>{{ number_format($returnAccounting['no_bookkeeping_rows']) }}</b></div>
|
||||
</div>
|
||||
@if ($returnAccounting['logistics_rows'] > 0)
|
||||
<p class="sr-hint">
|
||||
已识别 {{ number_format($returnAccounting['logistics_rows']) }} 行“不扣本厂家、转物流记账”
|
||||
@if (! empty($returnAccounting['logistics_companies']))
|
||||
,物流公司:{{ implode('、', $returnAccounting['logistics_companies']) }}
|
||||
@endif
|
||||
。厂家端显示相同备注和“转物流记账”标记,厂家扣减金额按调整后的净数量/金额计算。
|
||||
</p>
|
||||
@endif
|
||||
<div class="sr-scroll">
|
||||
<table class="sr-return-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">发货时间</th><th scope="col">条形码</th><th scope="col">商品名称</th>
|
||||
<th scope="col">厂家扣减数量</th><th scope="col">厂家扣减金额</th><th scope="col">记账归属</th>
|
||||
<th scope="col">物流记账数量</th><th scope="col">物流记账金额</th><th scope="col">物流公司</th>
|
||||
<th scope="col">备注</th><th scope="col">快递单号</th><th scope="col">对账人</th><th scope="col">确认状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($financeReturnPanel['rows'] as $row)
|
||||
<tr @class(['is-logistics' => $row['_accounting_target'] === '物流'])>
|
||||
<td>{{ $row['send_time'] ?? '—' }}</td>
|
||||
<td>{{ $row['barcode'] ?? '—' }}</td>
|
||||
<td>{{ $row['sku_name'] ?? '—' }}</td>
|
||||
<td class="is-num">{{ $row['_accounting_target'] === '不记账' ? '—' : number_format($row['_supplier_qty'], 2) }}</td>
|
||||
<td class="is-num">{{ $row['_accounting_target'] === '不记账' ? '—' : number_format($row['_supplier_amount'], 2) }}</td>
|
||||
<td>
|
||||
<span class="sr-tag {{ $row['_accounting_target'] === '物流' ? 'is-logistics' : ($row['_accounting_target'] === '不记账' ? 'is-no-bookkeeping' : 'is-match') }}">
|
||||
{{ $row['_accounting_label'] }}
|
||||
</span>
|
||||
@if ($row['_accounting_note'] !== '按厂家退货扣减')
|
||||
<div class="sr-muted" style="font-size:.7rem">{{ $row['_accounting_note'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">{{ $row['_logistics_qty'] > 0 ? number_format($row['_logistics_qty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['_logistics_amount'] > 0 ? number_format($row['_logistics_amount'], 2) : '—' }}</td>
|
||||
<td>{{ $row['_logistics_company'] !== '' ? $row['_logistics_company'] : '—' }}</td>
|
||||
<td>{{ $row['remark'] ?? '—' }}</td>
|
||||
<td>{{ $row['express_no'] ?? '—' }}</td>
|
||||
<td>{{ $row['reconciler'] ?? '—' }}</td>
|
||||
<td>
|
||||
@if ($row['status'] === 'confirmed')
|
||||
<span class="sr-tag is-match">已确认</span>
|
||||
@elseif ($row['status'] === 'disputed')
|
||||
<span class="sr-tag is-mismatch">异议中</span>
|
||||
@else
|
||||
<span class="sr-muted">待确认</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="13"><div class="sr-empty">当前月份和对账人筛选下没有退货数据。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if ($financeReturnPanel['pages'] > 1)
|
||||
@php
|
||||
$returnPageCount = $financeReturnPanel['pages'];
|
||||
$returnPageCurrent = $financeReturnPanel['page'];
|
||||
$returnPageStart = max(1, min($returnPageCurrent - 4, $returnPageCount - 8));
|
||||
$returnPageEnd = min($returnPageCount, $returnPageStart + 8);
|
||||
$returnPageUrl = fn (int $number): string => route('business.pages.show', array_filter([
|
||||
'section' => $section['slug'],
|
||||
'page' => $page['slug'],
|
||||
'supplier' => $selectedSupplier,
|
||||
'month' => $month,
|
||||
'reconciler' => $reconciler,
|
||||
'finance_return_page' => $number,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''));
|
||||
@endphp
|
||||
<div class="sr-pagination">
|
||||
<span>第 {{ $returnPageCurrent }} / {{ $returnPageCount }} 页(每页 {{ $financeReturnPanel['per_page'] }} 行)</span>
|
||||
@if ($returnPageCurrent > 1)
|
||||
<a class="sr-button is-small" href="{{ $returnPageUrl($returnPageCurrent - 1) }}">上一页</a>
|
||||
@endif
|
||||
@for ($number = $returnPageStart; $number <= $returnPageEnd; $number++)
|
||||
<a class="sr-button is-small {{ $number === $returnPageCurrent ? 'is-primary' : '' }}"
|
||||
@if ($number === $returnPageCurrent) aria-current="page" @endif
|
||||
href="{{ $returnPageUrl($number) }}">{{ $number }}</a>
|
||||
@endfor
|
||||
@if ($returnPageCurrent < $returnPageCount)
|
||||
<a class="sr-button is-small" href="{{ $returnPageUrl($returnPageCurrent + 1) }}">下一页</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
<div class="supplier-reconciliation-page">
|
||||
@php
|
||||
$formatReturnQty = static function (mixed $value): string {
|
||||
$quantity = (float) $value;
|
||||
|
||||
return number_format($quantity, abs($quantity - round($quantity)) < 0.00001 ? 0 : 2);
|
||||
};
|
||||
@endphp
|
||||
<style>
|
||||
.supplier-reconciliation-page { display: grid; gap: 16px; }
|
||||
.supplier-reconciliation-page .sr-card { min-width: 0; max-width: 100%; padding: 16px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: var(--vip-surface, #fff); display: grid; gap: 12px; }
|
||||
@ -30,14 +37,27 @@
|
||||
}
|
||||
.supplier-reconciliation-page .sr-return-banner .material-icons-outlined { color: var(--vip-content-accent-dark, #856632); font-size: 18px; }
|
||||
.supplier-reconciliation-page .sr-return-status { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--vip-content-muted, #595959); font-size: .75rem; }
|
||||
.supplier-reconciliation-page .sr-return-summary { display: grid; grid-template-columns: repeat(5, minmax(145px, 1fr)); gap: 12px; }
|
||||
.supplier-reconciliation-page .sr-return-stat { min-width: 0; padding: 16px 18px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 10px; background: #fff; display: grid; gap: 8px; }
|
||||
.supplier-reconciliation-page .sr-return-stat.is-total { border-color: #E4DCCB; background: var(--vip-content-accent-soft, #F7F0DE); }
|
||||
.supplier-reconciliation-page .sr-return-stat > span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
|
||||
.supplier-reconciliation-page .sr-return-stat strong { color: var(--vip-content-ink, #171717); font-size: 1.375rem; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.supplier-reconciliation-page .sr-return-stat strong small { margin-left: 3px; color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 500; }
|
||||
.supplier-reconciliation-page .sr-return-stat em { color: var(--vip-content-muted, #595959); font-size: .8125rem; font-style: normal; font-variant-numeric: tabular-nums; }
|
||||
.supplier-reconciliation-page .sr-return-table { min-width: 1420px; }
|
||||
.supplier-reconciliation-page .sr-return-scroll { max-height: min(62vh, 560px); overflow: auto; scrollbar-gutter: stable; }
|
||||
.supplier-reconciliation-page .sr-return-scroll thead th { position: sticky; top: 0; z-index: 4; box-shadow: 0 1px 0 var(--vip-border, #D8D2C7); }
|
||||
.supplier-reconciliation-page .sr-filter { display: grid; grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(135px, .8fr)) minmax(150px, 1fr) minmax(125px, .75fr); gap: 10px 12px; align-items: end; padding: 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; }
|
||||
.supplier-reconciliation-page .sr-filter-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; }
|
||||
.supplier-reconciliation-page .sr-date-presets { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; grid-column: 1 / -1; margin-top: -2px; }
|
||||
.supplier-reconciliation-page .sr-date-presets > span { margin-right: 2px; color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
|
||||
@media (max-width: 960px) {
|
||||
.supplier-reconciliation-page .sr-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.supplier-reconciliation-page .sr-return-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.supplier-reconciliation-page .sr-filter { grid-template-columns: minmax(0, 1fr); }
|
||||
.supplier-reconciliation-page .sr-return-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
.supplier-reconciliation-page .sr-type-tag {
|
||||
display: inline-flex; align-items: center; height: 22px; padding: 0 8px;
|
||||
@ -46,6 +66,9 @@
|
||||
.supplier-reconciliation-page .sr-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 4px; font-size: .75rem; white-space: nowrap; }
|
||||
.supplier-reconciliation-page .sr-tag.is-match { background: #ECF4EC; color: #3F7A46; }
|
||||
.supplier-reconciliation-page .sr-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
|
||||
.supplier-reconciliation-page .sr-tag.is-logistics { margin-left: 4px; background: #F7F0DE; color: #856632; }
|
||||
.supplier-reconciliation-page .sr-tag.is-no-bookkeeping { margin-left: 4px; background: #EFEAE0; color: #595959; }
|
||||
.supplier-reconciliation-page tr.is-logistics td { background: #FFFDF7; }
|
||||
.supplier-reconciliation-page .sr-inline-form { display: inline-flex; }
|
||||
.supplier-reconciliation-page .sr-return-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.supplier-reconciliation-page .sr-return-panel > .sr-return-actions { gap: 12px; }
|
||||
@ -60,18 +83,31 @@
|
||||
|
||||
<div class="sr-card">
|
||||
<h2>厂家对账单 · 上传与实时数据</h2>
|
||||
<form method="GET" class="sr-toolbar" aria-label="选择厂家">
|
||||
<label class="sr-field"><span>厂家</span>
|
||||
<select name="supplier" onchange="this.form.submit()">
|
||||
<form method="GET" class="sr-toolbar" aria-label="查询厂家">
|
||||
<label class="sr-field"><span>厂家名称</span>
|
||||
<input type="search"
|
||||
name="supplier"
|
||||
value="{{ $supplierQuery !== '' ? $supplierQuery : $selectedSupplier }}"
|
||||
list="supplier-name-options"
|
||||
placeholder="输入厂家名称搜索"
|
||||
autocomplete="off"
|
||||
required>
|
||||
<datalist id="supplier-name-options">
|
||||
@foreach ($supplierNames as $supplier)
|
||||
<option value="{{ $supplier }}" @selected($selectedSupplier === $supplier)>{{ $supplier }}</option>
|
||||
<option value="{{ $supplier }}"></option>
|
||||
@endforeach
|
||||
</select>
|
||||
</datalist>
|
||||
</label>
|
||||
<button type="submit" class="sr-button is-primary">查询厂家</button>
|
||||
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
|
||||
</form>
|
||||
@if ($supplierNotFound)
|
||||
<div class="biz-alert biz-alert-error" role="alert">
|
||||
未找到名为「{{ $supplierQuery }}」的厂家,请输入完整名称并从搜索建议中选择。
|
||||
</div>
|
||||
@endif
|
||||
<p class="sr-hint">
|
||||
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号、单据编号(采购单号)可以留空;
|
||||
厂家只能按模板上传(表头列名与顺序必须与模板完全一致):快递名称、快递单号可以留空;采购单号建议填写(用于按采购单号+日期+条码核对,未填时仅在日期+条码唯一匹配时自动匹配);
|
||||
单价或金额留空时按数量自动补齐(都留空按 0,未定价行可直接导出上传);其余列必填;
|
||||
不符合模板或数值格式的整份失败并提示具体行号。厂家上传成功后,下面的数据与「财务端数据」的比对结果立即更新。
|
||||
</p>
|
||||
@ -88,28 +124,6 @@
|
||||
</div>
|
||||
|
||||
@if ($selectedSupplier !== '')
|
||||
<div class="sr-card">
|
||||
<h2>{{ $selectedSupplier }} · 上传记录</h2>
|
||||
<div class="sr-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th scope="col">上传时间</th><th scope="col">文件名</th><th scope="col">行数</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($uploads as $upload)
|
||||
<tr>
|
||||
<td class="sr-muted">{{ $upload['uploaded_at'] ?? '—' }}</td>
|
||||
<td>{{ $upload['filename'] ?? '—' }}</td>
|
||||
<td class="is-num">{{ number_format((int) ($upload['rows'] ?? 0)) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="3"><div class="sr-empty">该厂家还没有上传过对账单。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sr-card">
|
||||
<h2>{{ $selectedSupplier }} · 上传的对账数据(共 {{ number_format($statementTotal) }} 行)</h2>
|
||||
<div class="sr-scroll">
|
||||
@ -178,28 +192,56 @@
|
||||
</div>
|
||||
|
||||
<div class="sr-card sr-return-panel">
|
||||
<div class="sr-return-summary" aria-label="退货汇总">
|
||||
<div class="sr-return-stat is-total">
|
||||
<span>退货件数</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['return_qty']) }}<small>件</small></strong>
|
||||
</div>
|
||||
<div class="sr-return-stat is-total">
|
||||
<span>退货金额</span>
|
||||
<strong>¥{{ number_format($returnPanel['total_amount'], 2) }}</strong>
|
||||
</div>
|
||||
<div class="sr-return-stat">
|
||||
<span>已确认</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['confirmed_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['confirmed_amount'], 2) }}</em>
|
||||
</div>
|
||||
<div class="sr-return-stat">
|
||||
<span>异议中</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['disputed_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['disputed_amount'], 2) }}</em>
|
||||
</div>
|
||||
<div class="sr-return-stat">
|
||||
<span>待确认</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['pending_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['pending_amount'], 2) }}</em>
|
||||
</div>
|
||||
@if ($returnPanel['accounting_summary']['logistics_rows'] > 0)
|
||||
<div class="sr-return-stat">
|
||||
<span>转物流记账</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['accounting_summary']['logistics_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['accounting_summary']['logistics_amount'], 2) }}</em>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="sr-return-banner" role="note">
|
||||
<span class="material-icons-outlined" aria-hidden="true">info</span>
|
||||
<span>
|
||||
<strong>① 退货扣减确认</strong>
|
||||
@if ($returnPanel['is_filtered'])
|
||||
(筛选出 {{ number_format($returnPanel['total_rows']) }} 行,全部 {{ number_format($returnPanel['all_rows']) }} 行,筛选结果合计扣减 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
|
||||
(当前筛选 {{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
|
||||
如当前筛选结果核对无误,可点「全部确认当前筛选」;其他退货行不受影响。
|
||||
@else
|
||||
(共 {{ number_format($returnPanel['total_rows']) }} 行,合计扣减 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
|
||||
(共 {{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
|
||||
核对无误请点「全部确认无异议」;个别有问题的行,在该行单独点「异议」,复核通过前该行暂不扣减。
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
<div class="sr-return-status">
|
||||
<span>已确认 {{ $returnPanel['confirmed_count'] }} 行(¥{{ number_format($returnPanel['confirmed_amount'], 2) }})</span>
|
||||
<span>· 异议 {{ $returnPanel['disputed_count'] }} 行(暂不扣减 ¥{{ number_format($returnPanel['disputed_amount'], 2) }})</span>
|
||||
<span>· 待确认 {{ max($returnPanel['total_rows'] - $returnPanel['confirmed_count'] - $returnPanel['disputed_count'], 0) }} 行</span>
|
||||
</div>
|
||||
<form method="GET"
|
||||
action="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"
|
||||
class="sr-filter"
|
||||
aria-label="查询退货明细">
|
||||
aria-label="查询退货明细"
|
||||
data-return-filter-form>
|
||||
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
|
||||
<label class="sr-field"><span>关键字</span>
|
||||
<input type="text" name="return_keyword"
|
||||
@ -208,10 +250,18 @@
|
||||
placeholder="条形码 / 商品名称 / 供应商条码 / 配货单号 / 快递单号">
|
||||
</label>
|
||||
<label class="sr-field"><span>发货日期起</span>
|
||||
<input type="date" name="return_date_from" value="{{ $returnPanel['filters']['date_from'] }}">
|
||||
<input type="date"
|
||||
id="business-return-date-from"
|
||||
name="return_date_from"
|
||||
value="{{ $returnPanel['filters']['date_from'] }}"
|
||||
title="点击选择开始日期">
|
||||
</label>
|
||||
<label class="sr-field"><span>发货日期止</span>
|
||||
<input type="date" name="return_date_to" value="{{ $returnPanel['filters']['date_to'] }}">
|
||||
<input type="date"
|
||||
id="business-return-date-to"
|
||||
name="return_date_to"
|
||||
value="{{ $returnPanel['filters']['date_to'] }}"
|
||||
title="点击选择结束日期">
|
||||
</label>
|
||||
<label class="sr-field"><span>仓库名称</span>
|
||||
<select name="return_warehouse">
|
||||
@ -229,17 +279,24 @@
|
||||
<option value="disputed" @selected($returnPanel['filters']['status'] === 'disputed')>异议中</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="sr-date-presets" role="group" aria-label="快捷发货日期">
|
||||
<span>快捷日期</span>
|
||||
<button type="button" class="sr-button is-small" data-return-date-range="last-7">近 7 天</button>
|
||||
<button type="button" class="sr-button is-small" data-return-date-range="this-month">本月</button>
|
||||
<button type="button" class="sr-button is-small" data-return-date-range="last-month">上月</button>
|
||||
<button type="button" class="sr-button is-small" data-return-date-range="all">全部日期</button>
|
||||
</div>
|
||||
<div class="sr-filter-actions">
|
||||
<button type="submit" class="sr-button is-primary">查询</button>
|
||||
<a class="sr-button"
|
||||
href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug'], 'supplier' => $selectedSupplier]) }}">重置</a>
|
||||
<a class="sr-button"
|
||||
href="{{ route('business.supplier-reconciliation.returns.export', array_merge(['supplier' => $selectedSupplier], $returnPanel['filter_query'])) }}">
|
||||
导出当前筛选结果({{ number_format($returnPanel['total_rows']) }} 行)
|
||||
导出当前筛选结果
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="sr-scroll">
|
||||
<div class="sr-scroll sr-return-scroll">
|
||||
<table class="sr-return-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -252,7 +309,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($returnPanel['rows'] as $row)
|
||||
<tr>
|
||||
<tr @class(['is-logistics' => $row['_accounting_target'] === '物流'])>
|
||||
<td>{{ $row['send_time'] ?? '—' }}</td>
|
||||
<td>{{ $row['supplier'] ?? '—' }}</td>
|
||||
<td>{{ $row['barcode'] ?? '—' }}</td>
|
||||
@ -261,7 +318,15 @@
|
||||
<td class="is-num">{{ $row['unit_price'] ?? '—' }}</td>
|
||||
<td class="is-num">{{ $row['amount'] ?? '—' }}</td>
|
||||
<td>{{ $row['supplier_barcode'] ?? '—' }}</td>
|
||||
<td>{{ $row['remark'] ?? '—' }}</td>
|
||||
<td>
|
||||
{{ $row['remark'] ?? '—' }}
|
||||
@if ($row['_accounting_target'] !== '厂家')
|
||||
<span class="sr-tag {{ $row['_accounting_target'] === '物流' ? 'is-logistics' : 'is-no-bookkeeping' }}">{{ $row['_accounting_label'] }}</span>
|
||||
@endif
|
||||
@if ($row['_accounting_note'] !== '按厂家退货扣减')
|
||||
<div class="sr-muted" style="font-size:.7rem">{{ $row['_accounting_note'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $row['title'] ?? '—' }}</td>
|
||||
<td>{{ $row['wms_pick_no'] ?? '—' }}</td>
|
||||
<td>{{ $row['warehouse'] ?? '—' }}</td>
|
||||
@ -299,9 +364,9 @@
|
||||
<p class="sr-hint">
|
||||
厂家端只展示核对退货所需的字段,左右滑动查看全部。
|
||||
@if ($returnPanel['is_filtered'])
|
||||
当前筛选出 {{ number_format($returnPanel['total_rows']) }} 行(全部 {{ number_format($returnPanel['all_rows']) }} 行),导出包含当前查询条件下的全部数据。
|
||||
当前筛选结果:{{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }},导出包含当前查询条件下的全部数据。
|
||||
@else
|
||||
退货共 {{ number_format($returnPanel['total_rows']) }} 行,导出包含该厂家的全部退货数据。
|
||||
退货共 {{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }},导出包含该厂家的全部退货数据。
|
||||
@endif
|
||||
按导入顺序展示,每页 {{ $returnPanel['per_page'] }} 行(第 {{ $returnPanel['page'] }} / {{ $returnPanel['pages'] }} 页)。
|
||||
</p>
|
||||
@ -357,4 +422,82 @@
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($selectedSupplier !== '')
|
||||
<div class="sr-card">
|
||||
<h2>{{ $selectedSupplier }} · 上传记录</h2>
|
||||
<div class="sr-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th scope="col">上传时间</th><th scope="col">文件名</th><th scope="col">行数</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($uploads as $upload)
|
||||
<tr>
|
||||
<td class="sr-muted">{{ $upload['uploaded_at'] ?? '—' }}</td>
|
||||
<td>{{ $upload['filename'] ?? '—' }}</td>
|
||||
<td class="is-num">{{ number_format((int) ($upload['rows'] ?? 0)) }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="3"><div class="sr-empty">该厂家还没有上传过对账单。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.querySelector('[data-return-filter-form]');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = form.querySelector('#business-return-date-from');
|
||||
const to = form.querySelector('#business-return-date-to');
|
||||
const iso = (date) => [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
|
||||
[from, to].forEach((input) => {
|
||||
input?.addEventListener('click', () => {
|
||||
if (typeof input.showPicker === 'function') {
|
||||
try {
|
||||
input.showPicker();
|
||||
} catch (error) {
|
||||
// 浏览器已在日期面板中打开时忽略。
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
form.querySelectorAll('[data-return-date-range]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const today = new Date();
|
||||
const kind = button.dataset.returnDateRange;
|
||||
|
||||
if (kind === 'all') {
|
||||
from.value = '';
|
||||
to.value = '';
|
||||
} else if (kind === 'this-month') {
|
||||
from.value = iso(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
to.value = iso(today);
|
||||
} else if (kind === 'last-month') {
|
||||
from.value = iso(new Date(today.getFullYear(), today.getMonth() - 1, 1));
|
||||
to.value = iso(new Date(today.getFullYear(), today.getMonth(), 0));
|
||||
} else if (kind === 'last-7') {
|
||||
const start = new Date(today);
|
||||
start.setDate(start.getDate() - 6);
|
||||
from.value = iso(start);
|
||||
to.value = iso(today);
|
||||
}
|
||||
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,13 @@
|
||||
|
||||
<x-slot name="content">
|
||||
<div class="supplier-portal-page">
|
||||
@php
|
||||
$formatReturnQty = static function (mixed $value): string {
|
||||
$quantity = (float) $value;
|
||||
|
||||
return number_format($quantity, abs($quantity - round($quantity)) < 0.00001 ? 0 : 2);
|
||||
};
|
||||
@endphp
|
||||
<style>
|
||||
.supplier-portal-page { display: grid; gap: 16px; color: var(--vip-content-ink, #171717); }
|
||||
.supplier-portal-page .sp-card { min-width: 0; max-width: 100%; padding: 16px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 12px; background: #fff; display: grid; gap: 12px; }
|
||||
@ -28,20 +35,36 @@
|
||||
.supplier-portal-page .sp-alert.is-error { border-color: #E8C4C0; background: #FBEBE9; color: #A6392F; }
|
||||
.supplier-portal-page .sp-upload { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.supplier-portal-page .sp-summary { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--vip-content-muted, #595959); font-size: .75rem; }
|
||||
.supplier-portal-page .sp-return-summary { display: grid; grid-template-columns: repeat(5, minmax(145px, 1fr)); gap: 12px; }
|
||||
.supplier-portal-page .sp-return-stat { min-width: 0; padding: 16px 18px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 10px; background: #fff; display: grid; gap: 8px; }
|
||||
.supplier-portal-page .sp-return-stat.is-total { border-color: #E4DCCB; background: var(--vip-content-accent-soft, #F7F0DE); }
|
||||
.supplier-portal-page .sp-return-stat > span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
|
||||
.supplier-portal-page .sp-return-stat strong { color: var(--vip-content-ink, #171717); font-size: 1.375rem; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.supplier-portal-page .sp-return-stat strong small { margin-left: 3px; color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 500; }
|
||||
.supplier-portal-page .sp-return-stat em { color: var(--vip-content-muted, #595959); font-size: .8125rem; font-style: normal; font-variant-numeric: tabular-nums; }
|
||||
.supplier-portal-page .sp-return-table { min-width: 1420px; }
|
||||
.supplier-portal-page .sp-return-scroll { max-height: min(62vh, 560px); overflow: auto; scrollbar-gutter: stable; }
|
||||
.supplier-portal-page .sp-return-scroll thead th { position: sticky; top: 0; z-index: 4; box-shadow: 0 1px 0 var(--vip-border, #D8D2C7); }
|
||||
.supplier-portal-page .sp-filter { display: grid; grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(135px, .8fr)) minmax(150px, 1fr) minmax(125px, .75fr); gap: 10px 12px; align-items: end; padding: 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; }
|
||||
.supplier-portal-page .sp-filter-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; }
|
||||
.supplier-portal-page .sp-date-presets { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; grid-column: 1 / -1; margin-top: -2px; }
|
||||
.supplier-portal-page .sp-date-presets > span { margin-right: 2px; color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
|
||||
.supplier-portal-page .sp-pagination { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; }
|
||||
.supplier-portal-page .sp-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 4px; background: #EFEAE0; color: #595959; font-size: .75rem; white-space: nowrap; }
|
||||
.supplier-portal-page .sp-tag.is-match { background: #ECF4EC; color: #3F7A46; }
|
||||
.supplier-portal-page .sp-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
|
||||
.supplier-portal-page .sp-tag.is-logistics { margin-left: 4px; background: #F7F0DE; color: #856632; }
|
||||
.supplier-portal-page .sp-tag.is-no-bookkeeping { margin-left: 4px; background: #EFEAE0; color: #595959; }
|
||||
.supplier-portal-page tr.is-logistics td { background: #FFFDF7; }
|
||||
.supplier-portal-page .sp-inline-form { display: inline-flex; }
|
||||
.supplier-portal-page .sp-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
@media (max-width: 960px) {
|
||||
.supplier-portal-page .sp-filter { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.supplier-portal-page .sp-return-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.supplier-portal-page .sp-filter { grid-template-columns: minmax(0, 1fr); }
|
||||
.supplier-portal-page .sp-return-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -66,18 +89,13 @@
|
||||
</form>
|
||||
</div>
|
||||
<p class="sp-hint">
|
||||
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号、采购单号可留空,
|
||||
请在每月 5 号前上传上月对账单。表头名称、顺序和列数必须与模板完全一致;快递名称、快递单号可留空;
|
||||
采购单号建议填写,系统会按采购单号+日期+条码与我方数据核对;
|
||||
单价或金额留空时会按数量自动补齐。
|
||||
</p>
|
||||
<div class="sp-summary">
|
||||
@if (! empty($uploads[0]))
|
||||
<span>最近上传:{{ $uploads[0]['uploaded_at'] ?? '—' }}</span>
|
||||
<span>文件:{{ $uploads[0]['filename'] ?? '—' }}</span>
|
||||
<span>行数:{{ number_format((int) ($uploads[0]['rows'] ?? 0)) }}</span>
|
||||
@else
|
||||
<span>当前还没有上传过对账单。</span>
|
||||
@endif
|
||||
<span>退货明细:共 {{ number_format($returnPanel['total_rows']) }} 行</span>
|
||||
<span>退货件数:{{ $formatReturnQty($returnPanel['return_qty']) }} 件</span>
|
||||
<span>退货金额:¥{{ number_format($returnPanel['total_amount'], 2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -86,21 +104,47 @@
|
||||
<p class="sp-hint">
|
||||
这里展示本厂家的全部退货数据(包含待确认、已确认和异议中),按导入顺序排列。
|
||||
@if ($returnPanel['is_filtered'])
|
||||
当前筛选出 {{ number_format($returnPanel['total_rows']) }} 行。
|
||||
当前筛选结果:{{ $formatReturnQty($returnPanel['return_qty']) }} 件,退货金额 ¥{{ number_format($returnPanel['total_amount'], 2) }}。
|
||||
@endif
|
||||
</p>
|
||||
<div class="sp-summary">
|
||||
<span>退货合计 {{ number_format($returnPanel['total_rows']) }} 行</span>
|
||||
<span>扣减合计 ¥{{ number_format($returnPanel['total_amount'], 2) }}</span>
|
||||
<span>已确认 {{ $returnPanel['confirmed_count'] }} 行(¥{{ number_format($returnPanel['confirmed_amount'], 2) }})</span>
|
||||
<span>异议 {{ $returnPanel['disputed_count'] }} 行(暂不扣减 ¥{{ number_format($returnPanel['disputed_amount'], 2) }})</span>
|
||||
<span>待确认 {{ max($returnPanel['total_rows'] - $returnPanel['confirmed_count'] - $returnPanel['disputed_count'], 0) }} 行</span>
|
||||
<div class="sp-return-summary" aria-label="退货汇总">
|
||||
<div class="sp-return-stat is-total">
|
||||
<span>退货件数</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['return_qty']) }}<small>件</small></strong>
|
||||
</div>
|
||||
<div class="sp-return-stat is-total">
|
||||
<span>退货金额</span>
|
||||
<strong>¥{{ number_format($returnPanel['total_amount'], 2) }}</strong>
|
||||
</div>
|
||||
<div class="sp-return-stat">
|
||||
<span>已确认</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['confirmed_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['confirmed_amount'], 2) }}</em>
|
||||
</div>
|
||||
<div class="sp-return-stat">
|
||||
<span>异议中</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['disputed_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['disputed_amount'], 2) }}</em>
|
||||
</div>
|
||||
<div class="sp-return-stat">
|
||||
<span>待确认</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['pending_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['pending_amount'], 2) }}</em>
|
||||
</div>
|
||||
@if ($returnPanel['accounting_summary']['logistics_rows'] > 0)
|
||||
<div class="sp-return-stat">
|
||||
<span>转物流记账</span>
|
||||
<strong>{{ $formatReturnQty($returnPanel['accounting_summary']['logistics_qty']) }}<small>件</small></strong>
|
||||
<em>¥{{ number_format($returnPanel['accounting_summary']['logistics_amount'], 2) }}</em>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<form method="GET"
|
||||
action="{{ route('portal.supplier-reconciliation.index') }}"
|
||||
class="sp-filter"
|
||||
aria-label="查询退货明细">
|
||||
aria-label="查询退货明细"
|
||||
data-return-filter-form>
|
||||
<label class="sp-field"><span>关键字</span>
|
||||
<input type="text" name="return_keyword"
|
||||
value="{{ $returnPanel['filters']['keyword'] }}"
|
||||
@ -108,10 +152,18 @@
|
||||
placeholder="条形码 / 商品名称 / 供应商条码 / 配货单号 / 快递单号">
|
||||
</label>
|
||||
<label class="sp-field"><span>发货日期起</span>
|
||||
<input type="date" name="return_date_from" value="{{ $returnPanel['filters']['date_from'] }}">
|
||||
<input type="date"
|
||||
id="portal-return-date-from"
|
||||
name="return_date_from"
|
||||
value="{{ $returnPanel['filters']['date_from'] }}"
|
||||
title="点击选择开始日期">
|
||||
</label>
|
||||
<label class="sp-field"><span>发货日期止</span>
|
||||
<input type="date" name="return_date_to" value="{{ $returnPanel['filters']['date_to'] }}">
|
||||
<input type="date"
|
||||
id="portal-return-date-to"
|
||||
name="return_date_to"
|
||||
value="{{ $returnPanel['filters']['date_to'] }}"
|
||||
title="点击选择结束日期">
|
||||
</label>
|
||||
<label class="sp-field"><span>仓库名称</span>
|
||||
<select name="return_warehouse">
|
||||
@ -129,17 +181,24 @@
|
||||
<option value="disputed" @selected($returnPanel['filters']['status'] === 'disputed')>异议中</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="sp-date-presets" role="group" aria-label="快捷发货日期">
|
||||
<span>快捷日期</span>
|
||||
<button type="button" class="sp-button is-small" data-return-date-range="last-7">近 7 天</button>
|
||||
<button type="button" class="sp-button is-small" data-return-date-range="this-month">本月</button>
|
||||
<button type="button" class="sp-button is-small" data-return-date-range="last-month">上月</button>
|
||||
<button type="button" class="sp-button is-small" data-return-date-range="all">全部日期</button>
|
||||
</div>
|
||||
<div class="sp-filter-actions">
|
||||
<button type="submit" class="sp-button is-primary">查询</button>
|
||||
<a class="sp-button" href="{{ route('portal.supplier-reconciliation.index') }}">重置</a>
|
||||
<a class="sp-button"
|
||||
href="{{ route('portal.supplier-reconciliation.returns.export', $returnPanel['filter_query']) }}">
|
||||
导出当前筛选结果({{ number_format($returnPanel['total_rows']) }} 行)
|
||||
导出当前筛选结果
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="sp-scroll">
|
||||
<div class="sp-scroll sp-return-scroll">
|
||||
<table class="sp-return-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -152,7 +211,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($returnPanel['rows'] as $row)
|
||||
<tr>
|
||||
<tr @class(['is-logistics' => $row['_accounting_target'] === '物流'])>
|
||||
<td>{{ $row['send_time'] ?? '—' }}</td>
|
||||
<td>{{ $row['supplier'] ?? '—' }}</td>
|
||||
<td>{{ $row['barcode'] ?? '—' }}</td>
|
||||
@ -161,7 +220,15 @@
|
||||
<td class="is-num">{{ $row['unit_price'] ?? '—' }}</td>
|
||||
<td class="is-num">{{ $row['amount'] ?? '—' }}</td>
|
||||
<td>{{ $row['supplier_barcode'] ?? '—' }}</td>
|
||||
<td>{{ $row['remark'] ?? '—' }}</td>
|
||||
<td>
|
||||
{{ $row['remark'] ?? '—' }}
|
||||
@if ($row['_accounting_target'] !== '厂家')
|
||||
<span class="sp-tag {{ $row['_accounting_target'] === '物流' ? 'is-logistics' : 'is-no-bookkeeping' }}">{{ $row['_accounting_label'] }}</span>
|
||||
@endif
|
||||
@if ($row['_accounting_note'] !== '按厂家退货扣减')
|
||||
<div class="sp-muted" style="font-size:.7rem">{{ $row['_accounting_note'] }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $row['title'] ?? '—' }}</td>
|
||||
<td>{{ $row['wms_pick_no'] ?? '—' }}</td>
|
||||
<td>{{ $row['warehouse'] ?? '—' }}</td>
|
||||
@ -294,5 +361,58 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.querySelector('[data-return-filter-form]');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = form.querySelector('#portal-return-date-from');
|
||||
const to = form.querySelector('#portal-return-date-to');
|
||||
const iso = (date) => [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
|
||||
[from, to].forEach((input) => {
|
||||
input?.addEventListener('click', () => {
|
||||
if (typeof input.showPicker === 'function') {
|
||||
try {
|
||||
input.showPicker();
|
||||
} catch (error) {
|
||||
// 浏览器已在日期面板中打开时忽略。
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
form.querySelectorAll('[data-return-date-range]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const today = new Date();
|
||||
const kind = button.dataset.returnDateRange;
|
||||
|
||||
if (kind === 'all') {
|
||||
from.value = '';
|
||||
to.value = '';
|
||||
} else if (kind === 'this-month') {
|
||||
from.value = iso(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
to.value = iso(today);
|
||||
} else if (kind === 'last-month') {
|
||||
from.value = iso(new Date(today.getFullYear(), today.getMonth() - 1, 1));
|
||||
to.value = iso(new Date(today.getFullYear(), today.getMonth(), 0));
|
||||
} else if (kind === 'last-7') {
|
||||
const start = new Date(today);
|
||||
start.setDate(start.getDate() - 6);
|
||||
from.value = iso(start);
|
||||
to.value = iso(today);
|
||||
}
|
||||
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</x-slot>
|
||||
</x-layouts.portal>
|
||||
|
||||
@ -409,6 +409,10 @@ Route::get('workspace/supplier-reconciliation/returns/export', [SupplierReconcil
|
||||
->middleware('business.page:supplier-reconciliation,supplier-data')
|
||||
->name('business.supplier-reconciliation.returns.export');
|
||||
|
||||
Route::get('workspace/supplier-reconciliation/comparison/export', [SupplierReconciliationActions::class, 'exportComparison'])
|
||||
->middleware('business.page:supplier-reconciliation,finance-data')
|
||||
->name('business.supplier-reconciliation.comparison.export');
|
||||
|
||||
// 退货扣减确认:逐行确认/异议 + 一键全部确认
|
||||
Route::post('workspace/supplier-reconciliation/returns/confirm', [SupplierReconciliationActions::class, 'confirmReturn'])
|
||||
->middleware('business.page:supplier-reconciliation,supplier-data')
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
# --configure-dingtalk-callback
|
||||
# --allow-non-main 允许非 main 分支(测试用)
|
||||
# --allow-migrations 允许执行待办数据库迁移(需先按 AGENTS.md 核对)
|
||||
# --allow-rollback 允许覆盖不在本次版本里的线上提交(仅负责人决定撤回时使用)
|
||||
# --skip-regctl 跳过 regctl,直接用 docker 直传镜像
|
||||
# --keep-images <数量> 默认 2
|
||||
# --app-version <版本> 默认 3.1.15
|
||||
@ -27,6 +28,15 @@
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
release_includes_commit() {
|
||||
git merge-base --is-ancestor "$1" "$2" 2>/dev/null && return 0
|
||||
# Squash 改变提交身份;仅接受历史中完整文件树一致的提交。
|
||||
local previous_tree release_trees
|
||||
previous_tree="$(git rev-parse "${1}^{tree}" 2>/dev/null)" || return 1
|
||||
release_trees="$(git log --format=%T "$2" 2>/dev/null)" || return 1
|
||||
printf '%s\n' "$release_trees" | grep -Fx -- "$previous_tree" >/dev/null
|
||||
}
|
||||
|
||||
server='218.93.190.75'
|
||||
key_path=''
|
||||
password_file=''
|
||||
@ -45,6 +55,7 @@ bootstrap_server=0
|
||||
configure_dingtalk_callback=0
|
||||
allow_non_main=0
|
||||
allow_migrations=0
|
||||
allow_rollback=0
|
||||
skip_regctl=0
|
||||
app_version='3.1.15'
|
||||
# 国内网络访问 GitHub 受限时,composer 的 GitHub dist 会失败;默认走腾讯云镜像。
|
||||
@ -71,6 +82,7 @@ while [ "$#" -gt 0 ]; do
|
||||
--configure-dingtalk-callback) configure_dingtalk_callback=1 ;;
|
||||
--allow-non-main) allow_non_main=1 ;;
|
||||
--allow-migrations) allow_migrations=1 ;;
|
||||
--allow-rollback) allow_rollback=1 ;;
|
||||
--skip-regctl) skip_regctl=1 ;;
|
||||
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||
*) echo "未知参数:$1" >&2; exit 2 ;;
|
||||
@ -404,7 +416,8 @@ if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||
fail '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
|
||||
fi
|
||||
|
||||
if [ "$branch" = 'main' ] && git rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
|
||||
git fetch origin main --quiet || fail '无法更新远端 main,已停止发布。'
|
||||
if [ "$branch" = 'main' ]; then
|
||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
|
||||
fail '本地 main 与 origin/main 不一致,请先执行 git pull --ff-only 再发布。'
|
||||
fi
|
||||
@ -445,7 +458,6 @@ ssh_control_path="/tmp/caiwu-ssh-$$-$RANDOM.sock"
|
||||
ssh_options+=(-o ControlMaster=auto -o ControlPersist=600 -o "ControlPath=$ssh_control_path")
|
||||
tunnel_ssh_options=("${ssh_options[@]}" -o ControlPath=none)
|
||||
|
||||
remote_archive="$remote_path/image-$image_tag.tar.gz"
|
||||
temp_root="$(mktemp -d "${TMPDIR:-/tmp}/caiwu-deploy-XXXXXX")"
|
||||
local_tar="$temp_root/image.tar"
|
||||
local_archive="$temp_root/image.tar.gz"
|
||||
@ -500,13 +512,36 @@ for pair in \
|
||||
done
|
||||
|
||||
previous_sha="$(ssh "${ssh_options[@]}" "$ssh_target" "cat '$remote_path/.deployed-sha' 2>/dev/null || true" | tr -d '[:space:]')"
|
||||
if [ -n "$previous_sha" ] && ! printf '%s\n' "$previous_sha" | grep -Eq '^[0-9a-f]{40}$'; then
|
||||
fail '线上版本标记格式无效,已停止发布。'
|
||||
fi
|
||||
expected_previous_sha="${previous_sha:-none}"
|
||||
deploy_actor="$(printf '%s@%s' "$(git config user.name)" "$(hostname)" | tr -c 'A-Za-z0-9_.@:-' '_')"
|
||||
remote_release_path="$remote_path/releases/$image_tag-$(uuidgen | tr 'A-Z' 'a-z')"
|
||||
remote_archive="$remote_release_path/image-$image_tag.tar.gz"
|
||||
|
||||
if [ -n "$previous_sha" ]; then
|
||||
if git cat-file -e "${previous_sha}^{commit}" 2>/dev/null; then
|
||||
previous_sha_exists=1
|
||||
else
|
||||
# 线上可能是别人刚发的热修复分支,先拉取再判断。
|
||||
git fetch origin --quiet 2>/dev/null || true
|
||||
if git cat-file -e "${previous_sha}^{commit}" 2>/dev/null; then
|
||||
previous_sha_exists=1
|
||||
else
|
||||
previous_sha_exists=0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 防覆盖:线上当前版本必须包含在本次要发布的版本里,否则会撤掉别人已上线的改动。
|
||||
if [ "$allow_rollback" -eq 0 ]; then
|
||||
if [ "$previous_sha_exists" -eq 0 ]; then
|
||||
fail "线上当前版本 ${previous_sha} 在本地和 origin 都找不到(可能是未推送的分支),发布会把它覆盖。请让发布人先推送并合并进 main;确需撤回时显式添加 --allow-rollback。"
|
||||
fi
|
||||
if ! release_includes_commit "$previous_sha" "$deploy_sha"; then
|
||||
fail "线上当前版本($(git log -1 --format='%h %an %ad %s' --date=format:'%m-%d %H:%M' "$previous_sha"))不在本次发布的版本里,继续发布会把它及之后别人上线的改动撤掉。请先 git fetch 并把最新 main(或线上热修复分支)合入当前分支后重试;确需撤回时显式添加 --allow-rollback。"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$previous_sha_exists" -eq 0 ] && [ "$allow_migrations" -eq 0 ]; then
|
||||
fail "本地缺少服务器当前提交 ${previous_sha},无法安全比较数据库迁移。请先 git fetch 并核对全部待执行迁移,按 AGENTS.md 完成备份后使用 --allow-migrations。"
|
||||
@ -528,7 +563,7 @@ if [ "$bootstrap_server" -eq 1 ]; then
|
||||
bootstrap_remote=''
|
||||
fi
|
||||
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$remote_path'" \
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$remote_path' '$remote_release_path'" \
|
||||
|| fail '服务器 Docker 未就绪;首次发布请添加 --bootstrap-server。'
|
||||
|
||||
ensure_qiniu_storage_runtime
|
||||
@ -542,6 +577,9 @@ scp_to_remote \
|
||||
"$repo_root/compose.jiangling.yml" \
|
||||
"$repo_root/deploy/activate-release.sh" \
|
||||
"$repo_root/deploy/activate-incremental-release.sh" \
|
||||
"$repo_root/deploy/release-guard.sh" \
|
||||
"$repo_root/deploy/retired-activation.sh" \
|
||||
"$repo_root/deploy/install-release-protection.sh" \
|
||||
"$repo_root/deploy/config-db-init.sh" \
|
||||
"$repo_root/deploy/run-finance-batch.sh" \
|
||||
"$repo_root/deploy/clickhouse-resource-policy.xml" \
|
||||
@ -550,10 +588,7 @@ scp_to_remote \
|
||||
"$repo_root/deploy/host-nginx-8442-dingtalk.conf" \
|
||||
"$repo_root/deploy/setup-incremental-registry.sh" \
|
||||
"$repo_root/deploy/registry-config.yml" \
|
||||
"${ssh_target}:$remote_path/" || fail '上传运行配置失败。'
|
||||
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/update-runtime-url.sh' && '$remote_path/update-runtime-url.sh' '$app_url' '$remote_path'" \
|
||||
|| fail '更新服务器访问地址失败。'
|
||||
"${ssh_target}:$remote_release_path/" || fail '上传运行配置失败。'
|
||||
|
||||
if [ "$bootstrap_server" -eq 1 ]; then
|
||||
if ! docker image inspect "$database_image" >/dev/null 2>&1; then
|
||||
@ -570,11 +605,11 @@ if [ "$bootstrap_server" -eq 1 ]; then
|
||||
log "首次初始化全量包:${archive_size} MiB"
|
||||
|
||||
scp_to_remote "$local_archive" "${ssh_target}:$remote_archive" || fail '上传镜像失败。'
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_path/'*.sh && chmod 644 '$remote_path/config-db-init.sh' && '$remote_path/activate-release.sh' '$remote_archive' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' && '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml'" \
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "chmod 700 '$remote_release_path/'*.sh && '$remote_release_path/activate-release.sh' '$remote_archive' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' '$expected_previous_sha' '$deploy_actor' '$app_url' && '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml'" \
|
||||
|| fail '服务器激活、健康检查或增量仓库初始化失败。'
|
||||
else
|
||||
ssh "${ssh_options[@]}" -o ConnectTimeout=10 -o ConnectionAttempts=1 "$ssh_target" \
|
||||
"chmod 700 '$remote_path/'*.sh && chmod 644 '$remote_path/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remote_path/setup-incremental-registry.sh' '$remote_path' '$remote_path/registry-config.yml')" \
|
||||
"chmod 700 '$remote_release_path/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remote_release_path/setup-incremental-registry.sh' '$remote_path' '$remote_release_path/registry-config.yml')" \
|
||||
|| fail '服务器增量镜像仓库初始化失败。'
|
||||
|
||||
docker buildx build \
|
||||
@ -657,7 +692,7 @@ else
|
||||
incremental_mib="$(awk -v a="$after_bytes" -v b="$before_bytes" 'BEGIN { v = (a - b) / 1048576; if (v < 0) v = 0; printf "%.2f", v }')"
|
||||
log "增量新增镜像层:${incremental_mib} MiB;传输耗时:${upload_seconds} 秒"
|
||||
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "'$remote_path/activate-incremental-release.sh' '$server_registry_image' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images'" \
|
||||
ssh "${ssh_options[@]}" "$ssh_target" "'$remote_release_path/activate-incremental-release.sh' '$server_registry_image' '$image_tag' '$remote_path' '$deploy_sha' '$migration_flag' '$keep_images' '$expected_previous_sha' '$deploy_actor' '$app_url'" \
|
||||
|| fail '服务器增量激活或健康检查失败。'
|
||||
fi
|
||||
|
||||
|
||||
@ -22,6 +22,8 @@ param(
|
||||
[switch]$ConfigureDingTalkCallback,
|
||||
[switch]$AllowNonMain,
|
||||
[switch]$AllowMigrations,
|
||||
# 仅在负责人明确决定撤回线上某次发布时使用;默认禁止覆盖不在本次版本里的线上提交。
|
||||
[switch]$AllowRollback,
|
||||
[switch]$SkipRegctl
|
||||
)
|
||||
|
||||
@ -48,6 +50,21 @@ $buildkitConfig = Join-Path $repoRoot 'deploy/buildkitd.toml'
|
||||
$regctlVersion = 'v0.11.5'
|
||||
$regctlSha256 = '9b83975d464e4ee5a9f899a3ead26072ed67e9a8ed0f582897bfad8141b45938'
|
||||
|
||||
function Test-CaiwuReleaseIncludesCommit {
|
||||
param([string]$PreviousSha, [string]$ReleaseSha)
|
||||
|
||||
git merge-base --is-ancestor $PreviousSha $ReleaseSha 2>$null
|
||||
if ($LASTEXITCODE -eq 0) { return $true }
|
||||
|
||||
# Squash 会改变提交身份;只接受发布历史中完整文件树一致的提交。
|
||||
# 不按提交说明、部分文件或 patch 相似度放行。
|
||||
$previousTree = (git rev-parse "${PreviousSha}^{tree}" 2>$null) -join ''
|
||||
if ($LASTEXITCODE -ne 0) { return $false }
|
||||
$releaseTrees = @(git log --format=%T $ReleaseSha 2>$null)
|
||||
if ($LASTEXITCODE -ne 0) { return $false }
|
||||
return $releaseTrees -contains $previousTree
|
||||
}
|
||||
|
||||
function Invoke-Native {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@ -331,6 +348,11 @@ try {
|
||||
throw '工作区存在未提交的受控文件改动,拒绝构建。请先提交或处理清楚。'
|
||||
}
|
||||
|
||||
Invoke-Native { git fetch origin main --quiet } '无法更新远端 main,已停止发布。'
|
||||
if ($branch -eq 'main' -and (git rev-parse HEAD).Trim() -ne (git rev-parse origin/main).Trim()) {
|
||||
throw '本地 main 不是最新 origin/main,请先 git pull --ff-only 再发布。'
|
||||
}
|
||||
|
||||
$deploySha = (git rev-parse HEAD).Trim()
|
||||
$imageTag = $deploySha.Substring(0, 12)
|
||||
$fullImage = "${imageName}:$imageTag"
|
||||
@ -355,7 +377,6 @@ try {
|
||||
}
|
||||
Invoke-NativeWithRetry { ssh @sshOptions $sshTarget true } '服务器登录失败或已取消,已停止部署。' -Attempts 3
|
||||
if ($authSession.Directory) { $env:CAIWU_DEPLOY_PASSWORD_UPDATE = $null }
|
||||
$remoteArchive = "$RemotePath/image-$imageTag.tar.gz"
|
||||
|
||||
# deploy-auth.ps1 is dot-sourced and can clear the automatic script path
|
||||
# variable on PowerShell Core. Restore it before invoking the build helper.
|
||||
@ -419,6 +440,11 @@ try {
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw '无法连接服务器。'
|
||||
}
|
||||
if ($previousSha -and $previousSha -notmatch '^[0-9a-f]{40}$') { throw '线上版本标记格式无效,已停止发布。' }
|
||||
$expectedPreviousSha = if ($previousSha) { $previousSha } else { 'none' }
|
||||
$deployActor = (((git config user.name) -join '') + '@' + [Environment]::MachineName) -replace '[^A-Za-z0-9_.@:-]', '_'
|
||||
$remoteReleasePath = "$RemotePath/releases/$imageTag-$([Guid]::NewGuid().ToString('N'))"
|
||||
$remoteArchive = "$remoteReleasePath/image-$imageTag.tar.gz"
|
||||
|
||||
$previousShaExists = $false
|
||||
if ($previousSha) {
|
||||
@ -426,6 +452,24 @@ try {
|
||||
$previousShaExists = $LASTEXITCODE -eq 0
|
||||
}
|
||||
|
||||
if ($previousSha -and -not $previousShaExists) {
|
||||
# 线上可能是别人刚发的热修复分支,先拉取再判断,避免误判。
|
||||
git fetch origin --quiet 2>$null
|
||||
git cat-file -e "${previousSha}^{commit}" 2>$null
|
||||
$previousShaExists = $LASTEXITCODE -eq 0
|
||||
}
|
||||
|
||||
# 防覆盖:线上当前版本必须包含在本次要发布的版本里,否则发布会把别人已上线的改动撤掉。
|
||||
if ($previousSha -and -not $AllowRollback) {
|
||||
if (-not $previousShaExists) {
|
||||
throw "线上当前版本 $previousSha 在本地和 origin 都找不到(可能是未推送的分支),发布会把它覆盖。请让发布人先推送并合并进 main;确需撤回时显式添加 -AllowRollback。服务器未发生任何变更。"
|
||||
}
|
||||
if (-not (Test-CaiwuReleaseIncludesCommit -PreviousSha $previousSha -ReleaseSha $deploySha)) {
|
||||
$previousInfo = (git log -1 --format='%h %an %ad %s' --date=format:'%m-%d %H:%M' $previousSha) -join ''
|
||||
throw "线上当前版本($previousInfo)不在本次发布的版本里,继续发布会把它及之后别人上线的改动撤掉。请先 git fetch 并把最新 main(或线上热修复分支)合入当前分支后重试;确需撤回时显式添加 -AllowRollback。服务器未发生任何变更。"
|
||||
}
|
||||
}
|
||||
|
||||
if ($previousSha -and -not $previousShaExists -and -not $AllowMigrations) {
|
||||
throw "本地缺少服务器当前提交 $previousSha,无法安全比较数据库迁移。请先 git fetch 并核对全部待执行迁移,按 AGENTS.md 完成备份后使用 -AllowMigrations。"
|
||||
}
|
||||
@ -452,7 +496,7 @@ try {
|
||||
}
|
||||
|
||||
Invoke-Native {
|
||||
ssh @sshOptions $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath'"
|
||||
ssh @sshOptions $sshTarget "docker version >/dev/null && docker compose version >/dev/null && install -d -m 700 '$RemotePath' '$remoteReleasePath'"
|
||||
} '服务器 Docker 未就绪;首次发布请添加 -BootstrapServer。'
|
||||
|
||||
Ensure-QiniuBusinessStorageRuntime
|
||||
@ -463,6 +507,9 @@ try {
|
||||
compose.jiangling.yml `
|
||||
deploy/activate-release.sh `
|
||||
deploy/activate-incremental-release.sh `
|
||||
deploy/release-guard.sh `
|
||||
deploy/retired-activation.sh `
|
||||
deploy/install-release-protection.sh `
|
||||
deploy/config-db-init.sh `
|
||||
deploy/run-finance-batch.sh `
|
||||
deploy/clickhouse-resource-policy.xml `
|
||||
@ -471,13 +518,9 @@ try {
|
||||
deploy/host-nginx-8442-dingtalk.conf `
|
||||
deploy/setup-incremental-registry.sh `
|
||||
deploy/registry-config.yml `
|
||||
"${sshTarget}:$RemotePath/"
|
||||
"${sshTarget}:$remoteReleasePath/"
|
||||
} '上传运行配置失败。'
|
||||
|
||||
Invoke-Native {
|
||||
ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/update-runtime-url.sh' && '$RemotePath/update-runtime-url.sh' '$AppUrl' '$RemotePath'"
|
||||
} '更新服务器访问地址失败。'
|
||||
|
||||
if ($BootstrapServer) {
|
||||
docker image inspect $DatabaseImage 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
@ -497,12 +540,12 @@ try {
|
||||
Write-Host "首次初始化全量包:$archiveSizeMB MiB"
|
||||
Invoke-Native { scp @scpOptions @sshOptions $localArchive "${sshTarget}:$remoteArchive" } '上传镜像失败。'
|
||||
Invoke-Native {
|
||||
ssh @sshOptions $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && '$RemotePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
|
||||
ssh @sshOptions $sshTarget "chmod 700 '$remoteReleasePath/'*.sh && '$remoteReleasePath/activate-release.sh' '$remoteArchive' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' '$expectedPreviousSha' '$deployActor' '$AppUrl' && '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml'"
|
||||
} '服务器激活、健康检查或增量仓库初始化失败。'
|
||||
}
|
||||
else {
|
||||
Invoke-NativeWithRetry {
|
||||
ssh @sshOptions -o ConnectTimeout=10 -o ConnectionAttempts=1 $sshTarget "chmod 700 '$RemotePath/'*.sh && chmod 644 '$RemotePath/config-db-init.sh' && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$RemotePath/setup-incremental-registry.sh' '$RemotePath' '$RemotePath/registry-config.yml')"
|
||||
ssh @sshOptions -o ConnectTimeout=10 -o ConnectionAttempts=1 $sshTarget "chmod 700 '$remoteReleasePath/'*.sh && (curl -fsS -o /dev/null http://127.0.0.1:5000/v2/ || '$remoteReleasePath/setup-incremental-registry.sh' '$RemotePath' '$remoteReleasePath/registry-config.yml')"
|
||||
} '服务器增量镜像仓库初始化失败。'
|
||||
|
||||
# docker save 导出未压缩层,每次导入重新压缩会改变摘要,导致 Registry 无法复用。
|
||||
@ -618,7 +661,7 @@ try {
|
||||
Write-Host "增量新增镜像层:$incrementalMiB MiB;传输耗时:$([math]::Round($uploadTimer.Elapsed.TotalSeconds, 1)) 秒"
|
||||
|
||||
Invoke-Native {
|
||||
ssh @sshOptions $sshTarget "'$RemotePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages'"
|
||||
ssh @sshOptions $sshTarget "'$remoteReleasePath/activate-incremental-release.sh' '$serverRegistryImage' '$imageTag' '$RemotePath' '$deploySha' '$migrationFlag' '$KeepImages' '$expectedPreviousSha' '$deployActor' '$AppUrl'"
|
||||
} '服务器增量激活或健康检查失败。'
|
||||
}
|
||||
|
||||
|
||||
@ -8,16 +8,20 @@
|
||||
| --- | --- | --- | --- |
|
||||
| `toutiao` | 头条放心购 → 资金流水 | `business-mirror:import-douyin-authoritative` | 账单流水数据 › 店铺收款账户账单 › **抖音账户** |
|
||||
| `jd` | 京东商城 → 京东日账单 | `business-mirror:import-jd-daily` | **京东账单 + 京喜账单**(一次导入按店铺名拆两边,两边合计恒等于导出行数) |
|
||||
| `jd-wallet` | 京东商城 → 京东钱包 | `business-mirror:import-jd-wallet` | **京东网银钱包账户** |
|
||||
|
||||
## 用法
|
||||
|
||||
```powershell
|
||||
# 两个平台都跑:区间「月初 → 昨日」
|
||||
# 三个平台都跑:区间「月初 → 昨日」
|
||||
pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1
|
||||
|
||||
# 只跑京东,补历史月份
|
||||
pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform jd -Through 2026-07-31
|
||||
|
||||
# 只跑京东钱包,补历史月份
|
||||
pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform jd-wallet -Through 2026-07-31
|
||||
|
||||
# 只导出不导入(排查)
|
||||
pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform toutiao -Through 2026-10-05 -SkipImport
|
||||
```
|
||||
@ -26,9 +30,19 @@ pwsh -File scripts\erp-daily-bills\run-erp-daily.ps1 -Platform toutiao -Through
|
||||
- **无差异跳过**:该平台该月已导入且笔数/收入/支出完全一致时跳过导入(`-ForceImport` 可强制重刷);状态记在 `runtime/private/erp-exports/imported-state.json`。
|
||||
- 超过胜算同步导出上限(响应 `超出同步导出允许最大数据量`)时,脚本自动轮询「异步导出下载管理」取回本次任务的全部分片,逐片转 CSV 后合并成一份再导入;仍可用 `-Start/-Through` 分段导出。
|
||||
- 导入前用页面笔数(共 N 条)与导出行数核对,不一致就跳过导入(`-ForceImport` 可强制),避免只导入一部分。
|
||||
- 稳定性:导出失败自动重试一次(胜算页面偶发日历面板/授权弹窗异常);上传与"导入+回验"失败自动重试 3 次(含重新上传 CSV),用于吸收服务器 SSH 偶发断连。
|
||||
- 稳定性:导出最多重试 3 次(胜算页面偶发日历面板/授权弹窗异常);上传与"导入+回验"失败自动重试 3 次(含重新上传 CSV),用于吸收服务器 SSH 偶发断连。
|
||||
- 失败即失败:导出/转 CSV/笔数核对/上传/导入回验任一步失败,都会**不写本地状态、不归档**,并在结束时以退出码 1 结束(下次运行会重新导入),不会把失败当成功记入 `imported-state.json`。
|
||||
|
||||
## 覆盖校验
|
||||
|
||||
导入后可核对三个数据集在指定区间内是否逐月覆盖到截止日:
|
||||
|
||||
```powershell
|
||||
docker exec -u www-data caiwu_app php artisan business-mirror:verify-erp-daily-coverage --from=2026-07-01 --to=2026-10-19
|
||||
```
|
||||
|
||||
命令会逐个月份检查期间起止范围、有效版本行数和登记行数;缺月、只导到月中或行数不一致都会返回失败。
|
||||
|
||||
## 组成
|
||||
|
||||
| 文件 | 作用 |
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
*
|
||||
* --platform=toutiao 头条放心购 → 资金流水 (导入 import-douyin-authoritative → 抖音账户)
|
||||
* --platform=jd 京东商城 → 京东日账单 (导入 import-jd-daily → 京东账单 + 京喜账单,按店铺名拆分)
|
||||
* --platform=jd-wallet 京东商城 → 京东钱包 (导入 import-jd-wallet → 京东网银钱包账户)
|
||||
*
|
||||
* 用法:
|
||||
* node export-daily.mjs --platform=toutiao # 本月月初 → 昨日
|
||||
@ -24,6 +25,7 @@ const BASE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PLATFORMS = {
|
||||
toutiao: { label: '头条放心购', subTab: '资金流水', filePrefix: '头条放心购_资金流水', exportMainName: '头条放心购头条资金流水' },
|
||||
jd: { label: '京东商城', subTab: '京东日账单', filePrefix: '京东日账单', exportMainName: '京东商城京东日账单' },
|
||||
'jd-wallet': { label: '京东商城', subTab: '京东钱包', filePrefix: '京东钱包', exportMainName: '京东商城京东钱包' },
|
||||
};
|
||||
const arg = (name, fallback = null) => {
|
||||
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
|
||||
@ -41,7 +43,7 @@ const BILL_URL = 'https://ss.erp321.com/process/platform-bill-new';
|
||||
const platformKey = arg('platform', 'toutiao');
|
||||
const platform = PLATFORMS[platformKey];
|
||||
if (!platform) {
|
||||
throw new Error('未知平台:' + platformKey + '(可选 toutiao / jd)');
|
||||
throw new Error('未知平台:' + platformKey + '(可选 toutiao / jd / jd-wallet)');
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
@ -3,17 +3,19 @@
|
||||
|
||||
平台:toutiao = 头条放心购→资金流水(落 抖音账户)
|
||||
jd = 京东商城→京东日账单(一次导入按店铺名拆 京东账单 + 京喜账单)
|
||||
jd-wallet = 京东商城→京东钱包(落 京东网银钱包账户)
|
||||
|
||||
流程:导出 → xlsx 转 CSV → 与已导入期间比对(无差异则跳过)→ 上传 → 受控导入 → 页面回验 → 归档 → 写日志
|
||||
|
||||
用法:
|
||||
pwsh -File run-erp-daily.ps1 # 两个平台,区间「月初 → 昨日」
|
||||
pwsh -File run-erp-daily.ps1 # 三个平台,区间「月初 → 昨日」
|
||||
pwsh -File run-erp-daily.ps1 -Platform jd -Through 2026-07-31
|
||||
pwsh -File run-erp-daily.ps1 -Platform jd-wallet -Through 2026-07-31
|
||||
pwsh -File run-erp-daily.ps1 -Platform toutiao -SkipImport
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('toutiao', 'jd', 'both')][string]$Platform = 'both',
|
||||
[ValidateSet('toutiao', 'jd', 'jd-wallet', 'both')][string]$Platform = 'both',
|
||||
[string]$Through,
|
||||
[string]$Start,
|
||||
[switch]$SkipImport,
|
||||
@ -45,9 +47,10 @@ function Write-Log {
|
||||
$specs = @{
|
||||
toutiao = @{ label = '头条放心购'; artifact = 'douyin'; importCmd = 'business-mirror:import-douyin-authoritative'; verifyTabs = @('douyin'); split = $false }
|
||||
jd = @{ label = '京东日账单'; artifact = 'jd'; importCmd = 'business-mirror:import-jd-daily'; verifyTabs = @('jd-transactions', 'jingxi'); split = $true }
|
||||
'jd-wallet' = @{ label = '京东钱包'; artifact = 'jd-wallet'; importCmd = 'business-mirror:import-jd-wallet'; verifyTabs = @('jd-wallet'); split = $false }
|
||||
}
|
||||
|
||||
$targets = if ($Platform -eq 'both') { @('toutiao', 'jd') } else { @($Platform) }
|
||||
$targets = if ($Platform -eq 'both') { @('toutiao', 'jd', 'jd-wallet') } else { @($Platform) }
|
||||
$state = if (Test-Path -LiteralPath $stateFile) { (Get-Content -LiteralPath $stateFile -Raw | ConvertFrom-Json) } else { New-Object psobject }
|
||||
|
||||
$auth = Start-CaiwuDeploymentAuth -Server '218.93.190.75' -SshUser 'root'
|
||||
@ -144,7 +147,7 @@ try {
|
||||
# 2) 无差异跳过
|
||||
$key = $target + ':' + $month
|
||||
$prev = $state.$key
|
||||
$same = $prev -and ([int]$prev.rows -eq $rows) -and ([math]::Abs([double]$prev.income - $income) -lt 0.005) -and ([math]::Abs([double]$prev.expense - $expense) -lt 0.005)
|
||||
$same = $prev -and ([string]$prev.through -eq [string]$summary.through) -and ([int]$prev.rows -eq $rows) -and ([math]::Abs([double]$prev.income - $income) -lt 0.005) -and ([math]::Abs([double]$prev.expense - $expense) -lt 0.005)
|
||||
if ($same -and -not $ForceImport) {
|
||||
Write-Log ('该月已导入且无差异({0} 笔 / {1} / {2}),跳过导入' -f $rows, $income, $expense)
|
||||
continue
|
||||
@ -170,9 +173,11 @@ require '/var/www/html/vendor/autoload.php';
|
||||
`$repo = app(Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository::class);
|
||||
`$tabs = ['__TABS__'];
|
||||
foreach (`$tabs as `$tab) {
|
||||
`$r = `$tab === 'douyin'
|
||||
? `$repo->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1)
|
||||
: app(Modules\EcommerceFinance\Repositories\JdDailyBillRepository::class)->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1, `$tab);
|
||||
`$r = match (`$tab) {
|
||||
'douyin' => `$repo->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1),
|
||||
'jd-wallet' => app(Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository::class)->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1),
|
||||
default => app(Modules\EcommerceFinance\Repositories\JdDailyBillRepository::class)->paginate(['start' => '__START__', 'end' => '__THROUGH__'], 'occurred_at', 'desc', 1, 1, `$tab),
|
||||
};
|
||||
echo 'PAGE ' . `$tab . ' total=' . `$r['total'] . ' ' . json_encode(`$r['summary'], JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
||||
}
|
||||
PHP
|
||||
@ -197,12 +202,14 @@ rm -f /tmp/daily_bills.csv $remoteFile
|
||||
}
|
||||
|
||||
# 4) 记录状态 + 归档
|
||||
$state | Add-Member -NotePropertyName $key -NotePropertyValue ([pscustomobject]@{ rows = $rows; income = $income; expense = $expense; file = $csvFile; at = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') }) -Force
|
||||
$state | Add-Member -NotePropertyName $key -NotePropertyValue ([pscustomobject]@{ rows = $rows; income = $income; expense = $expense; through = $summary.through; file = $csvFile; at = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') }) -Force
|
||||
$state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $stateFile -Encoding UTF8
|
||||
|
||||
$shareDir = 'Z:\高祝兰\财务系统\9月'
|
||||
$monthNumber = [int]$month.Substring(5, 2)
|
||||
$shareDir = Join-Path 'Z:\高祝兰\财务系统' ('{0}月' -f $monthNumber)
|
||||
if (Test-Path -LiteralPath 'Z:\高祝兰\财务系统') {
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $shareDir -Force | Out-Null
|
||||
foreach ($f in $rawFiles) {
|
||||
Copy-Item -LiteralPath $f -Destination (Join-Path $shareDir ([IO.Path]::GetFileName($f))) -Force
|
||||
}
|
||||
|
||||
71
scripts/test-release-inclusion.ps1
Normal file
71
scripts/test-release-inclusion.ps1
Normal file
@ -0,0 +1,71 @@
|
||||
param([string]$BashPath='bash')
|
||||
$ErrorActionPreference='Stop'
|
||||
$repoRoot=Split-Path $PSScriptRoot -Parent
|
||||
$psSource=[IO.File]::ReadAllText((Join-Path $repoRoot 'scripts/deploy-from-workstation.ps1'))
|
||||
$parseErrors=$null; $tokens=$null
|
||||
$ast=[Management.Automation.Language.Parser]::ParseInput($psSource,[ref]$tokens,[ref]$parseErrors)
|
||||
if($parseErrors.Count){throw 'Deployment PowerShell syntax invalid'}
|
||||
$function=$ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Test-CaiwuReleaseIncludesCommit'},$true)
|
||||
if(-not $function){throw 'PowerShell inclusion guard missing'}
|
||||
. ([ScriptBlock]::Create($function.Extent.Text))
|
||||
$bashSource=[IO.File]::ReadAllText((Join-Path $repoRoot 'scripts/deploy-from-mac.sh')).Replace("`r`n","`n")
|
||||
$bashFunction=[regex]::Match($bashSource,'(?ms)^release_includes_commit\(\) \{\n.*?^\}').Value
|
||||
if(-not $bashFunction){throw 'Bash inclusion guard missing'}
|
||||
|
||||
$fixture=Join-Path ([IO.Path]::GetTempPath()) ('caiwu-release-inclusion-'+[guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $fixture | Out-Null
|
||||
$expectedFixture=[IO.Path]::GetFullPath($fixture)
|
||||
function Invoke-FixtureGit {
|
||||
param([Parameter(ValueFromRemainingArguments=$true)][string[]]$GitArgs)
|
||||
$output=@(git @GitArgs 2>&1)
|
||||
if($LASTEXITCODE -ne 0){throw "Fixture Git failed: $($GitArgs[0])"}
|
||||
return $output
|
||||
}
|
||||
function New-FixtureCommit {
|
||||
param([string]$Value,[string]$Message)
|
||||
[IO.File]::WriteAllText((Join-Path $fixture 'value.txt'),$Value)
|
||||
Invoke-FixtureGit add value.txt | Out-Null
|
||||
Invoke-FixtureGit commit -m $Message | Out-Null
|
||||
return (Invoke-FixtureGit rev-parse HEAD) -join ''
|
||||
}
|
||||
Push-Location $fixture
|
||||
try {
|
||||
Invoke-FixtureGit init -b main | Out-Null
|
||||
Invoke-FixtureGit config user.name 'Release guard fixture' | Out-Null
|
||||
Invoke-FixtureGit config user.email 'release-guard@example.invalid' | Out-Null
|
||||
$base=New-FixtureCommit 'base' 'base'
|
||||
Invoke-FixtureGit switch -c hotfix | Out-Null
|
||||
$hotfix=New-FixtureCommit 'hotfix content' 'hotfix'
|
||||
Invoke-FixtureGit switch main | Out-Null
|
||||
$squashed=New-FixtureCommit 'hotfix content' 'squashed identity'
|
||||
$release=New-FixtureCommit 'next authorized change' 'new release'
|
||||
Invoke-FixtureGit switch -c unrelated $base | Out-Null
|
||||
$unrelated=New-FixtureCommit 'different hotfix content' 'hotfix'
|
||||
$cases=@(
|
||||
@{name='direct ancestor';previous=$base;release=$release;expected=$true},
|
||||
@{name='same commit';previous=$hotfix;release=$hotfix;expected=$true},
|
||||
@{name='identical squash tree at head';previous=$hotfix;release=$squashed;expected=$true},
|
||||
@{name='identical squash tree in release ancestry';previous=$hotfix;release=$release;expected=$true},
|
||||
@{name='real unmerged change';previous=$unrelated;release=$release;expected=$false},
|
||||
@{name='rollback to before hotfix';previous=$hotfix;release=$base;expected=$false},
|
||||
@{name='missing deployed commit';previous=('0'*40);release=$release;expected=$false},
|
||||
@{name='missing release commit';previous=$hotfix;release=('0'*40);expected=$false}
|
||||
)
|
||||
foreach($case in $cases){
|
||||
$actual=Test-CaiwuReleaseIncludesCommit -PreviousSha $case.previous -ReleaseSha $case.release
|
||||
if($actual -ne $case.expected){throw "PowerShell failed: $($case.name)"}
|
||||
$code=$bashFunction+"`nrelease_includes_commit "+'"$1" "$2"'
|
||||
& $BashPath --noprofile --norc -c $code guard $case.previous $case.release
|
||||
$bashActual=$LASTEXITCODE -eq 0
|
||||
if($bashActual -ne $case.expected){throw "Bash failed: $($case.name)"}
|
||||
}
|
||||
"PASS: $($cases.Count) real Git cases for both PowerShell and Bash guards"
|
||||
} finally {
|
||||
Pop-Location
|
||||
$resolved=(Resolve-Path -LiteralPath $fixture).Path
|
||||
if([IO.Path]::GetFullPath($resolved) -ne $expectedFixture -or (Get-Item -LiteralPath $resolved).Attributes.HasFlag([IO.FileAttributes]::ReparsePoint)){
|
||||
throw 'Refusing cleanup outside the exact generated fixture directory'
|
||||
}
|
||||
Remove-Item -LiteralPath $resolved -Recurse -Force
|
||||
}
|
||||
exit 0
|
||||
@ -31,6 +31,9 @@ class AccountMappingPageTest extends FeatureTestCase
|
||||
|
||||
// 只影响 storage_path(),视图编译目录已在启动时解析,不受影响
|
||||
$this->app->useStoragePath($this->storageRoot);
|
||||
// Rules now live in the in-memory test database; seed it independently of local files.
|
||||
app(AccountMappingStore::class)->reset();
|
||||
\Illuminate\Support\Facades\DB::table(AccountMappingStore::EVENTS_TABLE)->delete();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
|
||||
289
tests/Feature/Business/CourierQuoteBillingRulesTest.php
Normal file
289
tests/Feature/Business/CourierQuoteBillingRulesTest.php
Normal file
@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Business\CourierQuoteChange;
|
||||
use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Support\CourierQuoteFreightCalculator;
|
||||
use App\Support\CourierQuoteStructure;
|
||||
use App\Support\QuoteManagementSnapshot;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class CourierQuoteBillingRulesTest extends FeatureTestCase
|
||||
{
|
||||
private array $files = [];
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** 用各家原表备注里自带的算例核对计费口径。 */
|
||||
public function testFreightMatchesWorkedExamplesFromQuoteRemarks(): void
|
||||
{
|
||||
$f = fn (array $structure, array $row, float $kg, string $rounding = 'none') => CourierQuoteStructure::freight(
|
||||
$structure, $row, CourierQuoteStructure::roundWeight($kg, $rounding)
|
||||
)['amount'];
|
||||
|
||||
// 江西新建中通:15kg 广东 = 5.5 + 10×0.43 + 5×0.63(累进,用分段表示)
|
||||
$progressive = ['mode' => 'segments'];
|
||||
$gd = ['region' => '广东', 'segments' => [
|
||||
['lo' => 0, 'hi' => 10, 'type' => 'waybill', 'base' => 5.5, 'rate' => 0.43],
|
||||
['lo' => 10, 'hi' => null, 'type' => 'first_add', 'base' => 9.8, 'fw' => 10, 'rate' => 0.63],
|
||||
]];
|
||||
$this->assertEqualsWithDelta(12.95, $f($progressive, $gd, 15), 0.0001);
|
||||
$this->assertEqualsWithDelta(9.8, $f($progressive, $gd, 10), 0.0001);
|
||||
|
||||
// 迈当极兔:8.1kg 广东向上取整按 9kg,3 + 9×1.1 = 12.9;3kg 内一口价 4.2
|
||||
$md = ['mode' => 'flat_then', 'flat_bands' => [3], 'then_mode' => 'waybill_rate', 'rate_tiers' => [10]];
|
||||
$mdRow = ['region' => '广东', 'flat' => [4.2], 'base' => 3, 'rates' => [1.1, 1.4]];
|
||||
$this->assertEqualsWithDelta(12.9, $f($md, $mdRow, 8.1, 'ceil'), 0.0001);
|
||||
$this->assertEqualsWithDelta(4.2, $f($md, $mdRow, 2.6, 'ceil'), 0.0001);
|
||||
$this->assertEqualsWithDelta(3 + 11 * 1.4, $f($md, $mdRow, 10.2, 'ceil'), 0.0001);
|
||||
|
||||
// 顺兴中通快运:10.4kg = (10.4-10)×1.07 + 首重最低收费 24
|
||||
$sx = ['mode' => 'first_additional', 'first_weight' => 10, 'rate_tiers' => []];
|
||||
$this->assertEqualsWithDelta(24.428, $f($sx, ['region' => '安徽', 'base' => 24, 'rates' => [1.07]], 10.4), 0.0001);
|
||||
$this->assertEqualsWithDelta(24.0, $f($sx, ['region' => '安徽', 'base' => 24, 'rates' => [1.07]], 6), 0.0001);
|
||||
|
||||
// 彩艺德邦:四舍五入,4.4 按 4kg(首重 5kg 内 13 元),5.5 按 6kg
|
||||
$cy = ['mode' => 'first_additional', 'first_weight' => 5, 'rate_tiers' => []];
|
||||
$cyRow = ['region' => '江苏', 'base' => 13, 'rates' => [1.6]];
|
||||
$this->assertEqualsWithDelta(13.0, $f($cy, $cyRow, 4.4, 'round'), 0.0001);
|
||||
$this->assertEqualsWithDelta(14.6, $f($cy, $cyRow, 5.5, 'round'), 0.0001);
|
||||
|
||||
// 常州圆通:按总重量所在区间取费率,区间上限含本数
|
||||
$yt = ['mode' => 'waybill_rate', 'rate_tiers' => [35, 50]];
|
||||
$ytRow = ['region' => '江苏', 'base' => 3.6, 'rates' => [0.48, 0.98, 1.28]];
|
||||
$this->assertEqualsWithDelta(3.6 + 35 * 0.48, $f($yt, $ytRow, 35), 0.0001);
|
||||
$this->assertEqualsWithDelta(3.6 + 35.5 * 0.98, $f($yt, $ytRow, 35.5), 0.0001);
|
||||
$this->assertEqualsWithDelta(3.6 + 60 * 1.28, $f($yt, $ytRow, 60), 0.0001);
|
||||
|
||||
// 派雅极兔:某一段原表为「不发」(价格为空);超过最后一段不在报价范围
|
||||
$py = ['mode' => 'flat_bands', 'flat_bands' => [3, 5]];
|
||||
$pyRow = ['region' => '江苏', 'flat' => [4, null]];
|
||||
$this->assertEqualsWithDelta(4.0, $f($py, $pyRow, 2), 0.0001);
|
||||
$this->assertSame('该重量段不发', CourierQuoteStructure::freight($py, $pyRow, 4)['reason']);
|
||||
$this->assertSame('超出报价重量范围', CourierQuoteStructure::freight($py, $pyRow, 6)['reason']);
|
||||
|
||||
$this->assertSame('特殊计费,需人工核算', CourierQuoteStructure::freight(['mode' => 'other'], [], 3)['reason']);
|
||||
}
|
||||
|
||||
public function testImportCommandWritesRulesAndCalculatorUsesThemByShipDate(): void
|
||||
{
|
||||
$old = $this->version('QB-101', '常州圆通其他', 'CZYTOQT', '2026-06-01', '2026-09-19');
|
||||
$new = $this->version('QB-102', '常州圆通其他', 'CZYTOQT', '2026-09-20', null);
|
||||
$seg = $this->version('QB-103', '江西新建中通', 'JXXJZTO', '2000-01-01', null);
|
||||
$manual = $this->version('QB-104', '华韵安能', 'HYAN', '2025-07-01', null);
|
||||
$file = $this->rulesFile([
|
||||
$this->waybill('QB-101', '常州圆通其他', 3.5),
|
||||
$this->waybill('QB-102', '常州圆通其他', 3.6),
|
||||
[
|
||||
'version_id' => 'QB-103', 'company_name' => '江西新建中通', 'conversion' => 'needs_segment_mode',
|
||||
'structure' => ['mode' => 'other', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => null, 'rate_tiers' => [], 'rounding' => 'none'],
|
||||
'segments_rows' => [['region' => '广东', 'segments' => [
|
||||
['lo' => 0, 'hi' => 10, 'type' => 'waybill', 'base' => 5.5, 'rate' => 0.43],
|
||||
['lo' => 10, 'hi' => null, 'type' => 'first_add', 'base' => 9.8, 'fw' => 10, 'rate' => 0.63],
|
||||
]]],
|
||||
'params' => ['volumetric_divisor' => 10000, 'weight_limit_kg' => 45, 'surcharges' => [], 'notes' => []],
|
||||
],
|
||||
['version_id' => 'QB-104', 'company_name' => '华韵安能', 'conversion' => 'manual', 'manual_reason' => '按体积计费'],
|
||||
]);
|
||||
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id, '--dry-run' => true])
|
||||
->assertExitCode(0);
|
||||
$this->assertNull($old->fresh()->billing_structure);
|
||||
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])
|
||||
->assertExitCode(0);
|
||||
$this->assertSame('waybill_rate', $old->fresh()->billing_structure['mode']);
|
||||
$this->assertEquals([35, 50], $new->fresh()->billing_structure['rate_tiers']);
|
||||
$this->assertSame('segments', $seg->fresh()->billing_structure['mode']);
|
||||
$this->assertNull($manual->fresh()->billing_structure);
|
||||
|
||||
$calc = new CourierQuoteFreightCalculator();
|
||||
// 发货日期决定用哪一版:9 月 1 日走旧版面单 3.5,9 月 25 日走新版面单 3.6
|
||||
$before = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-01'), '江苏省', 10);
|
||||
$this->assertSame('QB-101', $before['version_code']);
|
||||
$this->assertEqualsWithDelta(3.5 + 10 * 0.48, $before['amount'], 0.001);
|
||||
$after = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '江苏', 10);
|
||||
$this->assertSame('QB-102', $after['version_code']);
|
||||
$this->assertEqualsWithDelta(3.6 + 10 * 0.48, $after['amount'], 0.001);
|
||||
|
||||
// 计泡:60×50×40/6000 = 20kg > 实重 10kg
|
||||
$volume = $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '江苏', 10, 60, 50, 40);
|
||||
$this->assertEqualsWithDelta(20.0, $volume['volume_weight'], 0.001);
|
||||
$this->assertEqualsWithDelta(3.6 + 20 * 0.48, $volume['amount'], 0.001);
|
||||
|
||||
// 城市单列行优先:深圳走「深圳(广东)」,其他广东城市走广东
|
||||
$this->assertEqualsWithDelta(3.6 + 2 * 1.0, $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '广东省', 2, city: '深圳')['amount'], 0.001);
|
||||
$this->assertEqualsWithDelta(3.6 + 2 * 0.75, $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '广东省', 2, city: '广州')['amount'], 0.001);
|
||||
|
||||
$this->assertEqualsWithDelta(12.95, $calc->calculate('JXXJZTO', Carbon::parse('2026-10-01'), '广东', 15)['amount'], 0.001);
|
||||
// 没有快递编码的存量报价按快递名称查
|
||||
$seg->forceFill(['courier_expcode' => null])->save();
|
||||
$this->assertEqualsWithDelta(12.95, (new CourierQuoteFreightCalculator())->calculate('江西新建中通', Carbon::parse('2026-10-01'), '广东', 15)['amount'], 0.001);
|
||||
$seg->forceFill(['courier_expcode' => 'JXXJZTO'])->save();
|
||||
|
||||
// 起算重量:靓阁不足 16kg 按 16kg(落在 16-17kg 段 40 元)
|
||||
$this->version('QB-105', '靓阁快递', 'LGKD', '2024-03-12', null)->forceFill([
|
||||
'billing_structure' => ['mode' => 'flat_bands', 'flat_bands' => [17.0, 18.0], 'rounding' => 'none', 'min_weight' => 16.0, 'weight_limit' => 45.0],
|
||||
'billing_rows' => [['region' => '江苏', 'flat' => [40.0, 50.0], 'base' => null, 'rates' => []]],
|
||||
])->save();
|
||||
$light = $calc->calculate('LGKD', Carbon::parse('2026-10-01'), '江苏', 3);
|
||||
$this->assertSame(16.0, $light['billable_weight']);
|
||||
$this->assertEqualsWithDelta(40.0, $light['amount'], 0.001);
|
||||
$this->assertEqualsWithDelta(50.0, $calc->calculate('LGKD', Carbon::parse('2026-10-01'), '江苏', 17.5)['amount'], 0.001);
|
||||
$this->assertSame('超出限重 45kg', $calc->calculate('JXXJZTO', Carbon::parse('2026-10-01'), '广东', 46)['reason']);
|
||||
$this->assertStringContainsString('未覆盖目的地', $calc->calculate('CZYTOQT', Carbon::parse('2026-09-25'), '西藏', 2)['reason']);
|
||||
$this->assertStringContainsString('需人工核算', $calc->calculate('HYAN', Carbon::parse('2026-09-25'), '江苏', 2)['reason']);
|
||||
$this->assertSame('发货日期没有生效的报价', $calc->calculate('CZYTOQT', Carbon::parse('2026-01-01'), '江苏', 2)['reason']);
|
||||
|
||||
// 再导一次内容相同 → 跳过;内容不同且不加 --force → 整批报错不写
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])->assertExitCode(0);
|
||||
$changed = $this->rulesFile([$this->waybill('QB-102', '常州圆通其他', 9.9)]);
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $changed, '--company' => $this->company->id])->assertExitCode(1);
|
||||
$this->assertEqualsWithDelta(3.6, $new->fresh()->billing_rows[0]['base'], 0.0001);
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $changed, '--company' => $this->company->id, '--force' => true])->assertExitCode(0);
|
||||
$this->assertEqualsWithDelta(9.9, $new->fresh()->billing_rows[0]['base'], 0.0001);
|
||||
}
|
||||
|
||||
public function testImportRejectsWholeBatchWhenCompanyNameDoesNotMatch(): void
|
||||
{
|
||||
$a = $this->version('QB-201', '沭阳德邦', 'SYDB', '2026-02-01', null);
|
||||
$this->version('QB-202', '沭阳德邦其他', 'SYDBQT', '2026-02-01', null);
|
||||
$file = $this->rulesFile([$this->waybill('QB-201', '沭阳德邦', 6.8), $this->waybill('QB-202', '别的快递', 6.8)]);
|
||||
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $file, '--company' => $this->company->id])->assertExitCode(1);
|
||||
$this->assertNull($a->fresh()->billing_structure);
|
||||
}
|
||||
|
||||
public function testQuoteDetailShowsBillingSummary(): void
|
||||
{
|
||||
$version = $this->version('QB-301', '彩艺德邦', 'CYDB', '2024-07-01', null);
|
||||
$version->forceFill([
|
||||
'billing_structure' => ['mode' => 'first_additional', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => 5.0, 'rate_tiers' => [],
|
||||
'rounding' => 'round', 'volumetric_divisor' => 10000, 'weight_limit' => 60.0, 'per_ticket_surcharge' => 1.0],
|
||||
'billing_rows' => [['region' => '江苏', 'flat' => [], 'base' => 13.0, 'rates' => [1.6]]],
|
||||
])->save();
|
||||
|
||||
$quotes = (new QuoteManagementSnapshot('/nonexistent-billing.json'))->load()['quotes'];
|
||||
$quote = collect($quotes)->firstWhere('id', 'QB-301');
|
||||
|
||||
$this->assertStringContainsString('首重5kg内按首重价', $quote['billing_summary']);
|
||||
$this->assertStringContainsString('每票加收 1 元', $quote['billing_summary']);
|
||||
$this->assertStringContainsString('1 个地区', $quote['billing_summary']);
|
||||
$this->assertCalculatorAddsPerTicket();
|
||||
}
|
||||
|
||||
private function assertCalculatorAddsPerTicket(): void
|
||||
{
|
||||
$result = (new CourierQuoteFreightCalculator())->calculate('CYDB', Carbon::parse('2026-01-01'), '江苏', 5.5);
|
||||
$this->assertSame(6.0, $result['billable_weight']);
|
||||
$this->assertEqualsWithDelta(15.6, $result['amount'], 0.001);
|
||||
$this->assertSame('13+(6-5)×1.6+每票1', $result['formula']);
|
||||
}
|
||||
|
||||
public function test_import_rejects_duplicate_versions_negative_rates_and_unknown_segments_without_partial_writes(): void
|
||||
{
|
||||
$version = $this->version('QB-SAFE', '安全报价', 'SAFE', '2026-01-01', null);
|
||||
$valid = $this->waybill('QB-SAFE', '安全报价', 2);
|
||||
$negative = $valid;
|
||||
$negative['rows'][0]['rates'][0] = -1;
|
||||
$unknown = $valid;
|
||||
$unknown['conversion'] = 'needs_segment_mode';
|
||||
$unknown['segments_rows'] = [['region' => '江苏', 'segments' => [['lo' => 0, 'hi' => null, 'type' => 'unknown', 'base' => 2, 'rate' => 1]]]];
|
||||
foreach ([[$valid, $valid], [$negative], [$unknown], ['invalid-row']] as $items) {
|
||||
$this->artisan('courier-quotes:import-billing-rules', ['file' => $this->rulesFile($items), '--company' => $this->company->id])->assertExitCode(1);
|
||||
$this->assertNull($version->fresh()->billing_structure);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_city_specific_prices_require_the_matching_province_and_accept_chinese_parentheses(): void
|
||||
{
|
||||
$version = $this->version('QB-CITY', '城市报价', 'CITY', '2026-01-01', null);
|
||||
$version->forceFill([
|
||||
'billing_structure' => ['mode' => 'waybill_rate', 'rate_tiers' => []],
|
||||
'billing_rows' => [
|
||||
['region' => '深圳(广东)', 'base' => 5, 'rates' => [1]],
|
||||
['region' => '江苏', 'base' => 10, 'rates' => [2]],
|
||||
],
|
||||
])->save();
|
||||
$calculator = new CourierQuoteFreightCalculator();
|
||||
$this->assertEquals(7, $calculator->calculate('CITY', Carbon::parse('2026-10-01'), '广东省', 2, city: '深圳市')['amount']);
|
||||
$this->assertEquals(14, $calculator->calculate('CITY', Carbon::parse('2026-10-01'), '江苏省', 2, city: '深圳市')['amount']);
|
||||
}
|
||||
|
||||
private function waybill(string $code, string $name, float $base): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ([['江苏', 0.48, 0.98, 1.28], ['广东', 0.75, 3, 3.3], ['深圳(广东)', 1, 3, 3.3]] as [$region, $r1, $r2, $r3]) {
|
||||
$rows[] = ['region' => $region, 'flat' => [], 'base' => $base, 'rates' => [$r1, $r2, $r3], 'source_row' => 2];
|
||||
}
|
||||
|
||||
return [
|
||||
'version_id' => $code, 'company_name' => $name, 'conversion' => 'direct',
|
||||
'structure' => ['mode' => 'waybill_rate', 'flat_bands' => [], 'then_mode' => '', 'first_weight' => null, 'rate_tiers' => [35.0, 50.0], 'rounding' => 'none'],
|
||||
'rows' => $rows,
|
||||
'params' => ['volumetric_divisor' => 6000, 'weight_limit_kg' => null, 'surcharges' => [], 'notes' => ['面单预充6元/单']],
|
||||
];
|
||||
}
|
||||
|
||||
private function rulesFile(array $versions): string
|
||||
{
|
||||
$path = storage_path('framework/testing/billing-rules-' . uniqid('', true) . '.json');
|
||||
@mkdir(dirname($path), 0777, true);
|
||||
file_put_contents($path, json_encode(['versions' => $versions], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||
$this->files[] = $path;
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function version(string $code, string $name, string $expcode, string $from, ?string $to): CourierQuoteVersion
|
||||
{
|
||||
$change = CourierQuoteChange::create([
|
||||
'company_id' => $this->company->id,
|
||||
'number' => 'BG-T-' . $code,
|
||||
'source_quote_id' => $code,
|
||||
'source_sheet_name' => $name,
|
||||
'carrier_name' => $name,
|
||||
'quote_type' => '标准快递报价',
|
||||
'change_type' => '面单调整',
|
||||
'status' => CourierQuoteChange::STATUS_APPROVED,
|
||||
'effective_date' => $from,
|
||||
'is_retrospective' => false,
|
||||
'recalculation_status' => CourierQuoteChange::RECALCULATION_NOT_REQUIRED,
|
||||
'summary' => '测试报价',
|
||||
'created_by' => $this->user->id,
|
||||
'submitted_by' => $this->user->id,
|
||||
'submitted_at' => now(),
|
||||
'approved_by' => $this->user->id,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
|
||||
return CourierQuoteVersion::create([
|
||||
'company_id' => $this->company->id,
|
||||
'version_code' => $code,
|
||||
'source_quote_id' => $code,
|
||||
'source_change_id' => $change->id,
|
||||
'sheet_name' => $name,
|
||||
'company_name' => $name,
|
||||
'carrier_name' => $name,
|
||||
'courier_expcode' => $expcode,
|
||||
'quote_type' => '标准快递报价',
|
||||
'effective_date' => $from,
|
||||
'end_date' => $to,
|
||||
'source_range' => 'A1:C2',
|
||||
'values_json' => json_encode([['省份', '面单', '费率']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'formulas_json' => json_encode([[]], JSON_THROW_ON_ERROR),
|
||||
'status' => 'approved',
|
||||
'signature_status' => '历史签署资料已归档',
|
||||
'created_by' => $this->user->id,
|
||||
'approved_by' => $this->user->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -254,7 +254,7 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
|
||||
return count($pairs) === 2
|
||||
&& $pairs[0]['waybill'] === 'DPK317138931411'
|
||||
&& $pairs[0]['surcharge_type'] === '退回费'
|
||||
&& $pairs[0]['courier_name'] === '曹县德邦小件'
|
||||
&& $pairs[0]['courier_name'] === ''
|
||||
&& $pairs[0]['amount'] === 10.0
|
||||
&& $pairs[1]['waybill'] === 'DPK1'
|
||||
&& $pairs[1]['surcharge_type'] === '超长费'
|
||||
@ -288,8 +288,8 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
|
||||
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$spreadsheet->getActiveSheet()->fromArray([
|
||||
['单号', '附加费类型', '快递名称', '金额'],
|
||||
['DPK317138931411', '退回费', '曹县德邦小件', 10],
|
||||
['单号', '附加费类型', '金额'],
|
||||
['DPK317138931411', '退回费', 10],
|
||||
['DPK1', '超长费'],
|
||||
]);
|
||||
$importPath = tempnam(sys_get_temp_dir(), 'surcharge_import_');
|
||||
@ -356,12 +356,11 @@ class CourierSurchargeSettlementPageTest extends FeatureTestCase
|
||||
try {
|
||||
$this->assertSame('单号', $plain($sheet->getCell('A1')->getValue()));
|
||||
$this->assertSame('附加费类型', $plain($sheet->getCell('B1')->getValue()));
|
||||
$this->assertSame('快递名称', $plain($sheet->getCell('C1')->getValue()));
|
||||
$this->assertSame('金额(可选)', $plain($sheet->getCell('D1')->getValue()));
|
||||
$this->assertSame('金额(可选)', $plain($sheet->getCell('C1')->getValue()));
|
||||
$this->assertNull($plain($sheet->getCell('D1')->getValue()));
|
||||
$this->assertSame('DPK317138931411', $plain($sheet->getCell('A2')->getValue()));
|
||||
$this->assertSame('退回费', $plain($sheet->getCell('B2')->getValue()));
|
||||
$this->assertSame('曹县德邦小件', $plain($sheet->getCell('C2')->getValue()));
|
||||
$this->assertEquals(20, $plain($sheet->getCell('D2')->getValue()));
|
||||
$this->assertEquals(20, $plain($sheet->getCell('C2')->getValue()));
|
||||
} finally {
|
||||
@unlink($path);
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ class PlantingWorkOrderPagesTest extends FeatureTestCase
|
||||
'task_empty_package_fee' => '0.00', 'reimbursement_amount' => '39.79', 'payee_name' => '黄文梳',
|
||||
'loss_result' => Calculator::RESULT_DEDUCT, 'loss_basis' => '已匹配发货,备注无拍A发B/拍A发A',
|
||||
'shipped_amount' => '40.60', 'shipped_freight' => '3.20', 'express_no' => 'YT001',
|
||||
'deduction_amount' => '207.40', 'deduction_detail' => "DNZ-01 × 2 × 100.5 = 201.00\n运费 6.40", 'cost_missing' => false,
|
||||
], $patch);
|
||||
}
|
||||
|
||||
@ -78,7 +79,9 @@ class PlantingWorkOrderPagesTest extends FeatureTestCase
|
||||
->assertSee('GD565967381')
|
||||
->assertDontSee('GD-EXEMPT')
|
||||
->assertDontSee('GD-NONE')
|
||||
->assertSee('2026-08-01');
|
||||
->assertSee('2026-08-01')
|
||||
->assertSee('207.40')
|
||||
->assertSee('DNZ-01 × 2 × 100.5 = 201.00');
|
||||
|
||||
$this->loginAs()->get($this->url('planting-loss', ['month' => '2026-09', 'result' => Calculator::RESULT_EXEMPT]))
|
||||
->assertOk()->assertSee('GD-EXEMPT')->assertDontSee('GD565967381');
|
||||
@ -104,6 +107,17 @@ class PlantingWorkOrderPagesTest extends FeatureTestCase
|
||||
$this->assertStringContainsString("GD-EXEMPT,'3316392626048007769,拍A发B", $csv);
|
||||
$this->assertStringNotContainsString('GD565967381', $csv);
|
||||
|
||||
// 发运营:按种菜报销字段导出,忽略损耗页的列设置。
|
||||
$operations = $this->loginAs()->get(route('business.planting-data.planting-loss.export', [
|
||||
'company_id' => $this->company->id, 'month' => '2026-09', 'result' => Calculator::RESULT_EXEMPT, 'format' => 'reimbursement',
|
||||
]));
|
||||
$operations->assertOk();
|
||||
$opsCsv = $operations->streamedContent();
|
||||
$this->assertStringContainsString(implode(',', array_values(PlantingReimbursementPageData::COLUMNS)), $opsCsv);
|
||||
$this->assertStringContainsString('GD-EXEMPT', $opsCsv);
|
||||
$this->assertStringNotContainsString('GD565967381', $opsCsv);
|
||||
$this->assertStringNotContainsString('损耗判定', $opsCsv);
|
||||
|
||||
$reimbursements = $this->loginAs()->get(route('business.planting-data.planting-reimbursements.export', [
|
||||
'company_id' => $this->company->id, 'month' => '2026-09',
|
||||
]));
|
||||
|
||||
@ -105,6 +105,10 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
'operation' => '商品与库存管理导入',
|
||||
]],
|
||||
'jushuitan_cost_price_history_count' => 1,
|
||||
'jushuitan_cost_price_observations' => [[
|
||||
'record_type_label' => '观测到成本价变化', 'before_price' => 499, 'after_price' => 500,
|
||||
'effect_start_time' => '2026-10-20 12:00:00', 'remark' => '发现时间,不是真实改价时间',
|
||||
]],
|
||||
'purchase_order_unit_price' => 65,
|
||||
'purchase_order_finished_at' => '2026-08-20 11:03:05',
|
||||
'purchase_order_id' => 827380,
|
||||
@ -143,7 +147,7 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
'last_succeeded_at' => '2026-09-01 03:02:00',
|
||||
],
|
||||
'jushuitan_cost_price_history_sync_status' => [
|
||||
'code' => 'ready',
|
||||
'code' => 'failed',
|
||||
'initialized' => true,
|
||||
'last_succeeded_at' => '2026-09-15 00:05:00',
|
||||
],
|
||||
@ -166,6 +170,8 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
->assertOk()
|
||||
->assertSee('data-product-catalog-page', false)
|
||||
->assertSeeText('普通商品资料')
|
||||
->assertSeeText('聚水潭成本价历史最近同步失败')
|
||||
->assertSeeText('观测 1 条(最近20条)')
|
||||
->assertSee('data-sync-state="healthy"', false)
|
||||
->assertSeeText('同步正常')
|
||||
->assertSeeText('最近同步 2026-08-15 17:51:03')
|
||||
|
||||
@ -8,6 +8,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\EcommerceFinance\Services\PayablesFinalImporter;
|
||||
use Modules\EcommerceFinance\Services\PayablesFinalRegistry;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class SupplierPayableFinalPagesTest extends FeatureTestCase
|
||||
@ -203,6 +204,97 @@ class SupplierPayableFinalPagesTest extends FeatureTestCase
|
||||
$this->assertStringNotContainsString('快递名称', $export->streamedContent());
|
||||
}
|
||||
|
||||
public function test_final_return_reconciliation_page_paginates_fifteen_rows(): void
|
||||
{
|
||||
$records = [];
|
||||
for ($index = 1; $index <= 20; $index++) {
|
||||
$records[sprintf('return:%02d', $index)] = [
|
||||
'summary_month' => '2026-09',
|
||||
'send_time' => '2026-09-30',
|
||||
'supplier' => '退货供应商',
|
||||
'barcode' => sprintf('RC-%02d', $index),
|
||||
'sku_name' => '退货商品',
|
||||
'qty' => '1',
|
||||
'unit_price' => '10',
|
||||
'amount' => '10',
|
||||
];
|
||||
}
|
||||
app(SupplierPayableOverrideStore::class)->saveBatch(
|
||||
'final:supplier:return-reconciliation',
|
||||
$records,
|
||||
(int) $this->company->id,
|
||||
(int) $this->user->id,
|
||||
);
|
||||
|
||||
$baseUrl = route('business.pages.show', [
|
||||
'company_id' => (int) $this->company->id,
|
||||
'section' => 'supplier-payables-final',
|
||||
'page' => 'return-reconciliation',
|
||||
]);
|
||||
$url = fn (int $page): string => $baseUrl . '?page=' . $page;
|
||||
|
||||
$this->loginAs()->get($url(1))
|
||||
->assertOk()
|
||||
->assertSeeText('每页 15 条')
|
||||
->assertSeeText('第 1 页 / 共 2 页')
|
||||
->assertSeeText('RC-01')
|
||||
->assertDontSeeText('RC-16');
|
||||
|
||||
$this->loginAs()->get($url(2))
|
||||
->assertOk()
|
||||
->assertSeeText('第 2 页 / 共 2 页')
|
||||
->assertSeeText('RC-16')
|
||||
->assertSeeText('RC-20')
|
||||
->assertDontSeeText('RC-01');
|
||||
}
|
||||
|
||||
public function test_final_payment_reconciliation_page_paginates_fifteen_rows(): void
|
||||
{
|
||||
$records = [];
|
||||
for ($index = 1; $index <= 20; $index++) {
|
||||
$records[sprintf('payment:%02d', $index)] = [
|
||||
'summary_month' => '2026-09',
|
||||
'stockNo' => sprintf('RK-%02d', $index),
|
||||
'purchaseNo' => sprintf('CG-%02d', $index),
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => '货款供应商',
|
||||
'skuCode' => sprintf('PM-%02d', $index),
|
||||
'productCode' => sprintf('P-%02d', $index),
|
||||
'skuName' => '货款商品',
|
||||
'qty' => '1',
|
||||
'unitPrice' => '10',
|
||||
'amount' => '10',
|
||||
];
|
||||
}
|
||||
app(SupplierPayableOverrideStore::class)->saveBatch(
|
||||
'final:supplier:payment-reconciliation',
|
||||
$records,
|
||||
(int) $this->company->id,
|
||||
(int) $this->user->id,
|
||||
);
|
||||
|
||||
$baseUrl = route('business.pages.show', [
|
||||
'company_id' => (int) $this->company->id,
|
||||
'section' => 'supplier-payables-final',
|
||||
'page' => 'payment-reconciliation',
|
||||
]);
|
||||
$url = fn (int $page): string => $baseUrl . '?page=' . $page;
|
||||
|
||||
$this->loginAs()->get($url(1))
|
||||
->assertOk()
|
||||
->assertSeeText('每页 15 条')
|
||||
->assertSeeText('第 1 页 / 共 2 页')
|
||||
->assertSeeText('PM-01')
|
||||
->assertDontSeeText('PM-16');
|
||||
|
||||
$this->loginAs()->get($url(2))
|
||||
->assertOk()
|
||||
->assertSeeText('第 2 页 / 共 2 页')
|
||||
->assertSeeText('PM-16')
|
||||
->assertSeeText('PM-20')
|
||||
->assertDontSeeText('PM-01');
|
||||
}
|
||||
|
||||
public function test_final_import_reads_files_larger_than_one_chunk(): void
|
||||
{
|
||||
$lines = ['月份,入库单号,采购单号,日期,供应商,货号,商品编码,规格名称,数量,单价,金额,预估单价,预估金额,供应商商品编码,备注,仓库名称,快递名称,快递单号,未发出,对账人'];
|
||||
|
||||
@ -105,6 +105,9 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||||
&& str_contains($text, 'B-1')
|
||||
&& str_contains($text, 'C-1')
|
||||
&& str_contains($text, 'D-1')
|
||||
&& str_contains($text, '采购单号:编号-B-1')
|
||||
&& str_contains($text, '日期:2026-09-30')
|
||||
&& str_contains($text, '厂家数量少 1')
|
||||
&& ! str_contains($text, '`A-1`')
|
||||
&& ! str_contains($text, '鸣飞家具');
|
||||
});
|
||||
@ -126,9 +129,10 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '张三',
|
||||
'scope' => 'row',
|
||||
'purchase_no' => '编号-B-1',
|
||||
'date' => '2026-09-30',
|
||||
'barcode' => 'B-1',
|
||||
'difference_type' => '金额不一致',
|
||||
'unit_price' => '50',
|
||||
'difference_type' => '数量/金额不一致',
|
||||
])
|
||||
->assertStatus(302)
|
||||
->assertSessionHas('success', '已推送 1 条差异到 晨丰 钉钉群(1 条消息)。');
|
||||
@ -155,8 +159,8 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||||
'reconciler' => '张三',
|
||||
'scope' => 'selected',
|
||||
'selected_rows' => [
|
||||
json_encode(['barcode' => 'B-1', 'type' => '金额不一致', 'unitPrice' => 50]),
|
||||
json_encode(['barcode' => 'D-1', 'type' => '厂家多', 'unitPrice' => 80]),
|
||||
json_encode(['purchaseNo' => '编号-B-1', 'date' => '2026-09-30', 'barcode' => 'B-1', 'type' => '数量/金额不一致']),
|
||||
json_encode(['purchaseNo' => '编号-D-1', 'date' => '2026-09-30', 'barcode' => 'D-1', 'type' => '厂家多']),
|
||||
],
|
||||
])
|
||||
->assertStatus(302)
|
||||
@ -198,14 +202,20 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||||
$rows[] = [
|
||||
'barcode' => sprintf('BULK-%02d', $index),
|
||||
'supplierBarcode' => '',
|
||||
'purchaseNo' => sprintf('PO-BULK-%02d', $index),
|
||||
'date' => '2026-09-30',
|
||||
'unitPrice' => 10,
|
||||
'mineUnitPrice' => 10,
|
||||
'theirsUnitPrice' => 10,
|
||||
'mineQty' => 2,
|
||||
'theirsQty' => 1,
|
||||
'mineAmount' => 20,
|
||||
'theirsAmount' => 10,
|
||||
'type' => '金额不一致',
|
||||
'qtyDifference' => -1,
|
||||
'amountDifference' => -10,
|
||||
'type' => '数量/金额不一致',
|
||||
'settleAmount' => 10,
|
||||
'note' => '金额/数量不一致:按金额低的一方结算',
|
||||
'note' => '厂家数量少 1;按金额低的一方结算',
|
||||
];
|
||||
}
|
||||
|
||||
@ -230,6 +240,8 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||||
] as $key => $row) {
|
||||
$store->save('final:supplier:payment-reconciliation', $key, $row + [
|
||||
'summary_month' => '2026-09',
|
||||
'purchaseNo' => '编号-' . $row['skuCode'],
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => '晨丰',
|
||||
'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
|
||||
@ -46,6 +46,8 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSeeText('厂家端数据')
|
||||
->assertSeeText('厂家多')
|
||||
->assertSeeText('取金额低的一方结算')
|
||||
->assertSeeText('采购单号 + 日期 + 商品编码')
|
||||
->assertSee('list="finance-supplier-options"', false)
|
||||
->assertSeeText('对账人')
|
||||
->assertSeeText('下载对账单模板');
|
||||
}
|
||||
@ -55,11 +57,13 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
$store->save('final:supplier:payment-reconciliation', 'line:000001', [
|
||||
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
|
||||
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-1', 'date' => '2026-09-30',
|
||||
'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
|
||||
'qty' => '2', 'unitPrice' => '128', 'amount' => '256', 'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
$store->save('final:supplier:payment-reconciliation', 'line:000002', [
|
||||
'summary_month' => '2026-09', 'supplier' => '鸣飞家具', 'skuCode' => 'SJ-1',
|
||||
'summary_month' => '2026-09', 'purchaseNo' => '编号-SJ-1', 'date' => '2026-09-30',
|
||||
'supplier' => '鸣飞家具', 'skuCode' => 'SJ-1',
|
||||
'qty' => '1', 'unitPrice' => '96', 'amount' => '96', 'reconciler' => '李四',
|
||||
], $companyId, null);
|
||||
|
||||
@ -81,15 +85,17 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
$store->save('final:supplier:payment-reconciliation', 'line:000001', [
|
||||
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
|
||||
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-1N1097V01', 'date' => '2026-09-30',
|
||||
'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
|
||||
'qty' => '2', 'unitPrice' => '128', 'amount' => '256', 'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
$store->save('final:supplier:payment-reconciliation', 'line:000002', [
|
||||
'summary_month' => '2026-09', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-2',
|
||||
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-2N1097V01', 'date' => '2026-09-30',
|
||||
'supplier' => '晨丰', 'skuCode' => 'ZSJ-2',
|
||||
'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
$this->seedStatement('晨丰', [
|
||||
$this->statementRow('ZSJ-1N1097V01', '128.00', '2', '256.00'),
|
||||
$this->statementRow('ZSJ-1N1097V01', '128.00', '3', '384.00'),
|
||||
$this->statementRow('ZSJ-3N1097V01', '80.00', '1', '80.00'),
|
||||
]);
|
||||
|
||||
@ -103,11 +109,161 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSeeText('比对明细')
|
||||
->assertSeeText('返回供应商列表')
|
||||
->assertSeeText('结算金额')
|
||||
->assertSeeText('采购单号')
|
||||
->assertSeeText('日期')
|
||||
->assertSeeText('数量差异(厂家-我方)')
|
||||
->assertSeeText('厂家多 1.00')
|
||||
->assertSeeText('厂家多 ¥128.00')
|
||||
->assertSeeText('编号-ZSJ-1N1097V01')
|
||||
->assertSeeText('导出比对汇总')
|
||||
->assertSee('sr-comparison-scroll', false)
|
||||
->assertSeeText('每页 15 条')
|
||||
->assertSeeText('ZSJ-3')
|
||||
->assertSeeText('ZSJ-2')
|
||||
->assertDontSeeText('各厂家上传情况');
|
||||
}
|
||||
|
||||
public function test_finance_supplier_filter_requires_an_exact_searchable_name(): void
|
||||
{
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
foreach (['晨丰', '鸣飞家具'] as $supplier) {
|
||||
$store->save('final:supplier:payment-reconciliation', 'supplier:' . $supplier, [
|
||||
'summary_month' => '2026-09',
|
||||
'purchaseNo' => 'PO-' . $supplier,
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => $supplier,
|
||||
'skuCode' => 'A-1',
|
||||
'qty' => '1',
|
||||
'unitPrice' => '10',
|
||||
'amount' => '10',
|
||||
'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
}
|
||||
|
||||
$pageUrl = route('business.pages.show', [
|
||||
'company_id' => $companyId,
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'finance-data',
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('晨丰'))
|
||||
->assertOk()
|
||||
->assertSeeText('晨丰 · 比对明细')
|
||||
->assertSee('value="晨丰"', false);
|
||||
|
||||
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('不存在的厂家'))
|
||||
->assertOk()
|
||||
->assertSeeText('未找到名为「不存在的厂家」的供应商')
|
||||
->assertDontSeeText('晨丰 · 比对明细')
|
||||
->assertDontSeeText('鸣飞家具 · 比对明细');
|
||||
}
|
||||
|
||||
public function test_finance_comparison_rows_are_paginated_fifteen_per_page(): void
|
||||
{
|
||||
$companyId = (int) $this->company->id;
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$statementRows = [];
|
||||
for ($i = 1; $i <= 20; $i++) {
|
||||
$purchaseNo = sprintf('PO-%02d', $i);
|
||||
$barcode = sprintf('A-%02d', $i);
|
||||
$store->save('final:supplier:payment-reconciliation', 'compare:' . $i, [
|
||||
'summary_month' => '2026-09',
|
||||
'purchaseNo' => $purchaseNo,
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => '晨丰',
|
||||
'skuCode' => $barcode,
|
||||
'qty' => '1',
|
||||
'unitPrice' => '10',
|
||||
'amount' => '10',
|
||||
'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
$statementRows[] = array_merge(
|
||||
$this->statementRow($barcode . 'N1097V01', '10.00', '1', '10.00'),
|
||||
['purchaseNo' => $purchaseNo],
|
||||
);
|
||||
}
|
||||
$this->seedStatement('晨丰', $statementRows);
|
||||
|
||||
$pageUrl = fn (int $page): string => route('business.pages.show', [
|
||||
'company_id' => $companyId,
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'finance-data',
|
||||
'supplier' => '晨丰',
|
||||
'compare_page' => $page,
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($pageUrl(1))
|
||||
->assertOk()
|
||||
->assertSeeText('第 1 / 2 页')
|
||||
->assertSeeText('每页 15 条')
|
||||
->assertSeeText('A-01')
|
||||
->assertSeeText('A-15')
|
||||
->assertDontSeeText('A-16')
|
||||
->assertDontSeeText('A-20');
|
||||
|
||||
$this->loginAs()->get($pageUrl(2))
|
||||
->assertOk()
|
||||
->assertSeeText('第 2 / 2 页')
|
||||
->assertSeeText('A-16')
|
||||
->assertSeeText('A-20')
|
||||
->assertDontSeeText('A-15')
|
||||
->assertDontSeeText('A-01');
|
||||
}
|
||||
|
||||
public function test_finance_comparison_export_includes_summary_and_all_filtered_rows(): void
|
||||
{
|
||||
$companyId = (int) $this->company->id;
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
foreach ([
|
||||
'mine:1' => ['purchaseNo' => 'PO-1', 'skuCode' => 'A-1', 'qty' => '2', 'unitPrice' => '100', 'amount' => '200', 'reconciler' => '张三'],
|
||||
'mine:2' => ['purchaseNo' => 'PO-2', 'skuCode' => 'B-1', 'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三'],
|
||||
'mine:other' => ['purchaseNo' => 'PO-OTHER', 'skuCode' => 'C-1', 'qty' => '1', 'unitPrice' => '30', 'amount' => '30', 'reconciler' => '李四'],
|
||||
] as $key => $row) {
|
||||
$store->save('final:supplier:payment-reconciliation', $key, $row + [
|
||||
'summary_month' => $key === 'mine:other' ? '2026-10' : '2026-09',
|
||||
'date' => '2026-09-30',
|
||||
'supplier' => '晨丰',
|
||||
], $companyId, null);
|
||||
}
|
||||
$this->seedStatement('晨丰', [
|
||||
array_merge($this->statementRow('A-1N1097V01', '100.00', '2', '200.00'), ['purchaseNo' => 'PO-1']),
|
||||
array_merge($this->statementRow('B-1N1097V01', '50.00', '2', '100.00'), ['purchaseNo' => 'PO-2']),
|
||||
array_merge($this->statementRow('D-1N1097V01', '80.00', '1', '80.00'), ['purchaseNo' => 'PO-D']),
|
||||
]);
|
||||
|
||||
$response = $this->loginAs()->get(route('business.supplier-reconciliation.comparison.export', [
|
||||
'supplier' => '晨丰',
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '张三',
|
||||
]));
|
||||
$content = $response->streamedContent();
|
||||
|
||||
$response->assertOk()
|
||||
->assertDownload()
|
||||
->assertHeader('content-type', 'text/csv; charset=UTF-8');
|
||||
|
||||
foreach ([
|
||||
'供应商,晨丰',
|
||||
'月份,2026-09',
|
||||
'对账人,张三',
|
||||
'双方一致,1',
|
||||
'厂家多,1',
|
||||
'数量/金额不一致,1',
|
||||
'厂家少,0',
|
||||
'采购单号,日期,我方商品编码,厂家条码,比对结果',
|
||||
'PO-1',
|
||||
'PO-2',
|
||||
'PO-D',
|
||||
'厂家多 1',
|
||||
'厂家多 ¥50.00',
|
||||
] as $expected) {
|
||||
$this->assertStringContainsString($expected, $content);
|
||||
}
|
||||
|
||||
$this->assertStringNotContainsString('PO-OTHER', $content);
|
||||
}
|
||||
|
||||
public function test_supplier_page_shows_uploads_and_return_rows(): void
|
||||
{
|
||||
$this->seedStatement('晨丰', [
|
||||
@ -132,7 +288,7 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSeeText('厂家对账单 · 上传与实时数据')
|
||||
->assertSeeText('上传记录')
|
||||
->assertSeeText('退货扣减确认')
|
||||
->assertSeeText('合计扣减')
|
||||
->assertSeeText('退货金额')
|
||||
->assertSeeText('全部确认无异议')
|
||||
->assertSeeText('待确认')
|
||||
->assertSeeText('条形码')
|
||||
@ -142,7 +298,15 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSeeText('查询')
|
||||
->assertSeeText('导出当前筛选结果');
|
||||
|
||||
$response->assertSee('sr-return-scroll', false);
|
||||
|
||||
$html = (string) $response->getContent();
|
||||
$uploadHistoryPosition = strpos($html, ' · 上传记录');
|
||||
$returnPanelPosition = strpos($html, '退货扣减确认');
|
||||
$this->assertNotFalse($uploadHistoryPosition, '内部页面应保留上传记录。');
|
||||
$this->assertNotFalse($returnPanelPosition, '内部页面应正常渲染退货面板。');
|
||||
$this->assertGreaterThan($returnPanelPosition, $uploadHistoryPosition, '上传记录应移动到页面最下面。');
|
||||
|
||||
$tableStart = strpos($html, '<table class="sr-return-table">');
|
||||
$tableEnd = $tableStart === false ? false : strpos($html, '</table>', $tableStart);
|
||||
$this->assertNotFalse($tableStart, '厂家退货表应正常渲染。');
|
||||
@ -155,10 +319,48 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_supplier_page_uses_a_searchable_exact_supplier_picker(): void
|
||||
{
|
||||
$this->seedStatement('晨丰', [
|
||||
$this->statementRow('ZSJ-1', '128.00', '2', '256.00'),
|
||||
]);
|
||||
$this->seedStatement('鸣飞家具', [
|
||||
array_merge($this->statementRow('SJ-1', '96.00', '1', '96.00'), ['supplier' => '鸣飞家具']),
|
||||
]);
|
||||
|
||||
$pageUrl = route('business.pages.show', [
|
||||
'company_id' => $this->company->id,
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'supplier-data',
|
||||
]);
|
||||
|
||||
$this->loginAs()->get($pageUrl)
|
||||
->assertOk()
|
||||
->assertSee('name="supplier"', false)
|
||||
->assertSee('list="supplier-name-options"', false)
|
||||
->assertSeeText('查询厂家')
|
||||
->assertSee('value="晨丰"', false)
|
||||
->assertSee('value="鸣飞家具"', false);
|
||||
|
||||
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('鸣飞家具'))
|
||||
->assertOk()
|
||||
->assertSeeText('鸣飞家具 · 上传记录')
|
||||
->assertDontSeeText('晨丰 · 上传记录');
|
||||
|
||||
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('不存在的厂家'))
|
||||
->assertOk()
|
||||
->assertSeeText('未找到名为「不存在的厂家」的厂家')
|
||||
->assertDontSeeText('晨丰 · 上传记录')
|
||||
->assertDontSeeText('鸣飞家具 · 上传记录');
|
||||
}
|
||||
|
||||
public function test_vendor_portal_shows_only_all_returns_for_the_logged_in_supplier(): void
|
||||
{
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
$this->seedStatement('鸣飞家具', [
|
||||
array_merge($this->statementRow('OTHER-SUPPLIER', '100.00', '1', '100.00'), ['supplier' => '鸣飞家具']),
|
||||
]);
|
||||
for ($i = 1; $i <= 20; $i++) {
|
||||
$store->save('final:supplier:return-reconciliation', sprintf('portal:%02d', $i), [
|
||||
'summary_month' => '2026-09',
|
||||
@ -198,6 +400,12 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSeeText('PORTAL-01')
|
||||
->assertDontSeeText('PORTAL-20')
|
||||
->assertDontSeeText('OTHER-SUPPLIER-RETURN')
|
||||
->assertDontSeeText('鸣飞家具')
|
||||
->assertDontSeeText('最近上传')
|
||||
->assertDontSee('supplier-name-options', false)
|
||||
->assertSee('sp-return-scroll', false)
|
||||
->assertSee('id="portal-return-date-from"', false)
|
||||
->assertSee('data-return-date-range="last-month"', false)
|
||||
->assertDontSeeText('预估单价');
|
||||
|
||||
$this->loginAs($vendor)->get($portalUrl . '?return_page=2')
|
||||
@ -283,6 +491,103 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', (int) $this->company->id));
|
||||
}
|
||||
|
||||
public function test_vendor_portal_rejects_a_statement_for_another_supplier(): void
|
||||
{
|
||||
$this->withExceptionHandling();
|
||||
$vendor = $this->createVendorPortalUser('晨丰');
|
||||
$file = UploadedFile::fake()->createWithContent(
|
||||
'statement.csv',
|
||||
"\xEF\xBB\xBF" . self::HEADER . "\n"
|
||||
. ",,PORTAL-WRONG-SUPPLIER,2026-09-30,鸣飞家具,ZSJ-1,规格甲,2,128.00,256.00\n",
|
||||
);
|
||||
|
||||
$this->loginAs($vendor)
|
||||
->post(route('portal.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
|
||||
'import' => $file,
|
||||
])
|
||||
->assertSessionHasErrors([
|
||||
'import' => '第 2 行:供应商必须填写「晨丰」,不能提交其他厂家的数据。',
|
||||
]);
|
||||
|
||||
$this->assertCount(0, app(SupplierStatementStore::class)->rows('晨丰', (int) $this->company->id));
|
||||
$this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', (int) $this->company->id));
|
||||
}
|
||||
|
||||
public function test_return_bookkeeping_note_is_identical_on_finance_and_supplier_views(): void
|
||||
{
|
||||
$companyId = (int) $this->company->id;
|
||||
app(SupplierPayableOverrideStore::class)->save(
|
||||
'final:supplier:payment-reconciliation',
|
||||
'mine:logistics:1',
|
||||
[
|
||||
'summary_month' => '2026-09',
|
||||
'purchaseNo' => 'PO-LOGISTICS-1',
|
||||
'date' => '2026-09-27',
|
||||
'supplier' => '曹县宇诺',
|
||||
'reconciler' => '陈亲园',
|
||||
'skuCode' => 'CZ008407',
|
||||
'qty' => '1',
|
||||
'unitPrice' => '259',
|
||||
'amount' => '259',
|
||||
],
|
||||
$companyId,
|
||||
null,
|
||||
);
|
||||
app(SupplierPayableOverrideStore::class)->save(
|
||||
'final:supplier:return-reconciliation',
|
||||
'return:logistics:1',
|
||||
[
|
||||
'summary_month' => '2026-09',
|
||||
'send_time' => '2026-09-27',
|
||||
'supplier' => '曹县宇诺',
|
||||
'barcode' => 'CZ008407N1178V01',
|
||||
'sku_name' => '款式二 160/80/75 黑胡桃色',
|
||||
'qty' => '1',
|
||||
'unit_price' => '259',
|
||||
'amount' => '259',
|
||||
'remark' => '原数量2件,CZ008407N1178V01*1记账物流',
|
||||
'title' => '常州松宇货运',
|
||||
'express_no' => '202609270079',
|
||||
'reconciler' => '陈亲园',
|
||||
],
|
||||
$companyId,
|
||||
null,
|
||||
);
|
||||
|
||||
$finance = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'finance-data',
|
||||
'supplier' => '曹县宇诺',
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '陈亲园',
|
||||
]));
|
||||
$finance->assertOk()
|
||||
->assertSeeText('退货扣减 · 财务与厂家同步')
|
||||
->assertSeeText('转物流记账')
|
||||
->assertSeeText('常州松宇货运')
|
||||
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流')
|
||||
->assertSeeText('259.00');
|
||||
|
||||
$supplier = $this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'supplier-data',
|
||||
'supplier' => '曹县宇诺',
|
||||
]));
|
||||
$supplier->assertOk()
|
||||
->assertSeeText('转物流记账')
|
||||
->assertSeeText('不扣本厂家,转常州松宇货运记账')
|
||||
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流');
|
||||
|
||||
$vendor = $this->createVendorPortalUser('曹县宇诺');
|
||||
$this->loginAs($vendor)->get(route('portal.supplier-reconciliation.index', [
|
||||
'company_id' => $companyId,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('转物流记账')
|
||||
->assertSeeText('不扣本厂家,转常州松宇货运记账')
|
||||
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流');
|
||||
}
|
||||
|
||||
public function test_statement_rows_are_paginated_fifteen_per_page(): void
|
||||
{
|
||||
$rows = [];
|
||||
@ -332,15 +637,19 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
|
||||
$this->loginAs()->get($pageUrl(1))
|
||||
->assertOk()
|
||||
->assertSeeText('每页 15 行')
|
||||
->assertSeeText('每页 10 行')
|
||||
->assertSeeText('第 1 / 2 页')
|
||||
->assertSeeText('RT-01')
|
||||
->assertSeeText('RT-10')
|
||||
->assertDontSeeText('RT-11')
|
||||
->assertDontSeeText('RT-20');
|
||||
|
||||
$this->loginAs()->get($pageUrl(2))
|
||||
->assertOk()
|
||||
->assertSeeText('第 2 / 2 页')
|
||||
->assertSeeText('RT-11')
|
||||
->assertSeeText('RT-20')
|
||||
->assertDontSeeText('RT-10')
|
||||
->assertDontSeeText('RT-01');
|
||||
}
|
||||
|
||||
@ -390,11 +699,14 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', $filters))
|
||||
->assertOk()
|
||||
->assertSeeText('筛选出 19 行,全部 21 行')
|
||||
->assertSeeText('当前筛选 19 件,退货金额 ¥1,900.00')
|
||||
->assertSee('value="2026-09-20"', false)
|
||||
->assertSee('value="2026-09-21"', false)
|
||||
->assertSee('data-return-date-range="last-month"', false)
|
||||
->assertSeeText('MATCH-01')
|
||||
->assertDontSeeText('MATCH-20')
|
||||
->assertDontSeeText('OTHER-01')
|
||||
->assertSeeText('导出当前筛选结果(19 行)');
|
||||
->assertSeeText('导出当前筛选结果');
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', $filters + ['return_page' => 2]))
|
||||
->assertOk()
|
||||
@ -404,6 +716,66 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
->assertSee('return_status=pending', false);
|
||||
}
|
||||
|
||||
public function test_return_summary_uses_quantity_and_amount_without_row_counts(): void
|
||||
{
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$companyId = (int) $this->company->id;
|
||||
$rows = [
|
||||
'summary:1' => ['qty' => '2', 'amount' => '200'],
|
||||
'summary:2' => ['qty' => '3', 'amount' => '300'],
|
||||
'summary:3' => ['qty' => '1', 'amount' => '50'],
|
||||
'summary:4' => ['qty' => '4', 'amount' => '400'],
|
||||
];
|
||||
foreach ($rows as $key => $values) {
|
||||
$store->save('final:supplier:return-reconciliation', $key, $values + [
|
||||
'summary_month' => '2026-09',
|
||||
'send_time' => '2026-09-20',
|
||||
'supplier' => '晨丰',
|
||||
'barcode' => strtoupper($key),
|
||||
'unit_price' => '100',
|
||||
], $companyId, null);
|
||||
}
|
||||
|
||||
$confirmations = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class);
|
||||
$confirmations->mark('晨丰', 'summary:1', 'confirmed', $companyId, null);
|
||||
$confirmations->mark('晨丰', 'summary:3', 'disputed', $companyId, null);
|
||||
|
||||
$vendor = $this->createVendorPortalUser('晨丰');
|
||||
$portal = $this->loginAs($vendor)->get(route('portal.supplier-reconciliation.index', [
|
||||
'company_id' => $companyId,
|
||||
]));
|
||||
$portal->assertOk()
|
||||
->assertSeeText('退货件数')
|
||||
->assertSeeText('退货金额')
|
||||
->assertSee('10<small>件</small>', false)
|
||||
->assertSeeText('¥950.00')
|
||||
->assertSee('2<small>件</small>', false)
|
||||
->assertSeeText('¥200.00')
|
||||
->assertSee('1<small>件</small>', false)
|
||||
->assertSeeText('¥50.00')
|
||||
->assertSee('7<small>件</small>', false)
|
||||
->assertSeeText('¥700.00')
|
||||
->assertDontSeeText('退货合计')
|
||||
->assertDontSeeText('扣减合计')
|
||||
->assertDontSeeText('已确认 2 行')
|
||||
->assertDontSeeText('异议 1 行')
|
||||
->assertDontSeeText('待确认 3 行');
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'company_id' => $companyId,
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'supplier-data',
|
||||
'supplier' => '晨丰',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('退货件数')
|
||||
->assertSeeText('退货金额')
|
||||
->assertSee('10<small>件</small>', false)
|
||||
->assertSeeText('¥950.00')
|
||||
->assertDontSeeText('退货合计')
|
||||
->assertDontSeeText('扣减合计');
|
||||
}
|
||||
|
||||
public function test_confirm_all_only_applies_to_the_current_return_filter(): void
|
||||
{
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
@ -520,7 +892,7 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
'supplier' => '晨丰',
|
||||
]))->assertOk();
|
||||
|
||||
$page()->assertSeeText('合计扣减 ¥2,653.00');
|
||||
$page()->assertSeeText('退货金额')->assertSeeText('¥2,653.00');
|
||||
|
||||
$this->loginAs()
|
||||
->post(route('business.supplier-reconciliation.return.confirm'), ['supplier' => '晨丰', 'key' => 'return:1'])
|
||||
@ -621,14 +993,14 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
public function test_matcher_applies_the_agreed_settlement_rules(): void
|
||||
{
|
||||
$mine = [
|
||||
['skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], // 一致
|
||||
['skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'], // 金额不一致(厂家低)
|
||||
['skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'], // 厂家少
|
||||
['purchaseNo' => 'PO-A-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
['purchaseNo' => 'PO-B-1', 'date' => '2026-09-30', 'skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'],
|
||||
['purchaseNo' => 'PO-C-1', 'date' => '2026-09-30', 'skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'],
|
||||
];
|
||||
$theirs = [
|
||||
['barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
['barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'],
|
||||
['barcode' => 'D-1', 'unitPrice' => '80', 'qty' => '1', 'amount' => '80'], // 厂家多
|
||||
['purchaseNo' => 'PO-A-1', 'createdAt' => '2026-09-30', 'barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
['purchaseNo' => 'PO-B-1', 'createdAt' => '2026-09-30', 'barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'],
|
||||
['purchaseNo' => 'PO-D-1', 'createdAt' => '2026-09-30', 'barcode' => 'D-1', 'unitPrice' => '80', 'qty' => '1', 'amount' => '80'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
@ -638,25 +1010,31 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$this->assertSame(1, $result['summary']['supplierMissing']);
|
||||
$this->assertSame(1, $result['summary']['supplierExtra']);
|
||||
$this->assertSame(330.0, $result['summary']['mineAmount']);
|
||||
$this->assertSame(330.0, $result['summary']['theirsAmount']);
|
||||
$this->assertSame(80.0, $result['summary']['supplierOverAmount']);
|
||||
$this->assertSame(250.0, $result['summary']['settleAmount']);
|
||||
$this->assertSame(80.0, $result['summary']['differenceAmount']);
|
||||
|
||||
$byBarcode = collect($result['rows'])->keyBy('barcode');
|
||||
$this->assertSame(50.0, $byBarcode['B-1']['settleAmount']);
|
||||
$this->assertSame(-1.0, $byBarcode['B-1']['qtyDifference']);
|
||||
$this->assertSame(-50.0, $byBarcode['B-1']['amountDifference']);
|
||||
$this->assertSame('PO-B-1', $byBarcode['B-1']['purchaseNo']);
|
||||
$this->assertSame('2026-09-30', $byBarcode['B-1']['date']);
|
||||
$this->assertSame(0.0, $byBarcode['C-1']['settleAmount']);
|
||||
$this->assertSame(0.0, $byBarcode['D-1']['settleAmount']);
|
||||
}
|
||||
|
||||
public function test_matcher_normalizes_supplier_barcode_suffix(): void
|
||||
{
|
||||
// 真实场景:我方货号不带厂家后缀,厂家条码带「厂家编号 + 版本」(CJ019312 对 CJ019312N1097V01)
|
||||
// 真实场景:我方商品编码与厂家条码的厂家编号/版本后缀不同,去掉后缀后应一致。
|
||||
$mine = [
|
||||
['skuCode' => 'CJ019312', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||||
['skuCode' => 'DNZ037602', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||||
['purchaseNo' => 'PO-CJ-1', 'date' => '2026-09-30', 'skuCode' => 'CJ019312', 'productCode' => 'CJ019312N1178V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||||
['purchaseNo' => 'PO-DNZ-1', 'date' => '2026-09-30', 'skuCode' => 'DNZ037602', 'productCode' => 'DNZ037602N1178V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||||
];
|
||||
$theirs = [
|
||||
['barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||||
['barcode' => 'DNZ037602N1097V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||||
['purchaseNo' => 'PO-CJ-1', 'createdAt' => '2026-09-30', 'barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||||
['purchaseNo' => 'PO-DNZ-1', 'createdAt' => '2026-09-30', 'barcode' => 'DNZ037602N1097V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
@ -669,6 +1047,92 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode'] ?? null);
|
||||
}
|
||||
|
||||
public function test_matcher_uses_product_code_instead_of_sku_code(): void
|
||||
{
|
||||
$mine = [[
|
||||
'purchaseNo' => 'PO-CODE-1',
|
||||
'date' => '2026-09-30',
|
||||
'skuCode' => 'SKU-ONLY',
|
||||
'productCode' => 'CJ019312N1178V01',
|
||||
'unitPrice' => '182.5',
|
||||
'qty' => '2',
|
||||
'amount' => '365',
|
||||
]];
|
||||
$theirs = [[
|
||||
'purchaseNo' => 'PO-CODE-1',
|
||||
'createdAt' => '2026-09-30',
|
||||
'barcode' => 'CJ019312N1097V01',
|
||||
'unitPrice' => '182.5',
|
||||
'qty' => '2',
|
||||
'amount' => '365',
|
||||
]];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
|
||||
$this->assertSame(1, $result['summary']['match']);
|
||||
$this->assertSame('CJ019312N1178V01', $result['rows'][0]['barcode']);
|
||||
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode']);
|
||||
}
|
||||
|
||||
public function test_matcher_uses_purchase_number_date_and_barcode_as_the_unique_key(): void
|
||||
{
|
||||
$mine = [
|
||||
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'],
|
||||
];
|
||||
$theirs = [
|
||||
['purchaseNo' => 'PO-1', 'createdAt' => '2026/9/30 09:30:00', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'],
|
||||
['purchaseNo' => 'PO-3', 'createdAt' => '2026-10-01', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
$rows = collect($result['rows'])->keyBy('purchaseNo');
|
||||
|
||||
$this->assertSame(SupplierStatementMatcher::TYPE_MISMATCH, $rows['PO-1']['type']);
|
||||
$this->assertSame('2026-09-30', $rows['PO-1']['date']);
|
||||
$this->assertSame(1.0, $rows['PO-1']['qtyDifference']);
|
||||
$this->assertSame(100.0, $rows['PO-1']['amountDifference']);
|
||||
$this->assertStringContainsString('厂家数量多 1', $rows['PO-1']['note']);
|
||||
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_MISSING, $rows['PO-2']['type']);
|
||||
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_EXTRA, $rows['PO-3']['type']);
|
||||
$this->assertSame(3.0, $result['summary']['supplierOverQty']);
|
||||
$this->assertSame(300.0, $result['summary']['supplierOverAmount']);
|
||||
}
|
||||
|
||||
public function test_matcher_can_fallback_only_when_missing_purchase_number_matches_one_row(): void
|
||||
{
|
||||
$mine = [
|
||||
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
];
|
||||
$theirs = [
|
||||
['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
|
||||
$this->assertSame(1, $result['summary']['match']);
|
||||
$this->assertSame('PO-1', $result['rows'][0]['purchaseNo']);
|
||||
$this->assertStringContainsString('按日期+商品编码唯一匹配', $result['rows'][0]['note']);
|
||||
}
|
||||
|
||||
public function test_matcher_does_not_guess_when_missing_purchase_number_is_ambiguous(): void
|
||||
{
|
||||
$mine = [
|
||||
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'],
|
||||
['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||||
];
|
||||
$theirs = [
|
||||
['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'],
|
||||
];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
|
||||
|
||||
$this->assertSame(0, $result['summary']['match']);
|
||||
$this->assertSame(1, $result['summary']['supplierExtra']);
|
||||
$this->assertSame(2, $result['summary']['supplierMissing']);
|
||||
$this->assertStringContainsString('无法唯一匹配', $result['rows'][0]['note']);
|
||||
}
|
||||
|
||||
public function test_header_mismatch_message_lists_the_differences(): void
|
||||
{
|
||||
$file = UploadedFile::fake()->createWithContent(
|
||||
|
||||
@ -35,16 +35,39 @@ class FinanceBatchIsolationTest extends TestCase
|
||||
private function runBatch(string $directory, array $arguments): Process
|
||||
{
|
||||
file_put_contents($directory . '/docker', '#!/bin/sh' . "\n"
|
||||
. 'if [ "$1" = inspect ]; then echo caiwu_akaunting:123456789abc; exit 0; fi' . "\n"
|
||||
. 'if [ "$1" = inspect ]; then case "$4" in *Mounts*) echo "$CAIWU_STORAGE_ROOT" ;; *) echo caiwu_akaunting:123456789abc ;; esac; exit 0; fi' . "\n"
|
||||
. 'printf "%s\n" "image=$IMAGE_TAG" "$@" > "$CAIWU_CAPTURE"' . "\n");
|
||||
chmod($directory . '/docker', 0700);
|
||||
$process = new Process(array_merge(['sh', dirname(__DIR__, 3) . '/deploy/run-finance-batch.sh'], $arguments), $directory, [
|
||||
'PATH' => $directory . ':' . getenv('PATH'), 'CAIWU_REMOTE_DIR' => $directory,
|
||||
'CAIWU_UPSTREAM_LOCK' => $directory . '/sync.lock', 'CAIWU_BATCH_LOCK_WAIT' => '0.1',
|
||||
'CAIWU_CAPTURE' => $directory . '/called',
|
||||
'CAIWU_STORAGE_ROOT' => $directory . '/storage',
|
||||
]);
|
||||
$process->run();
|
||||
|
||||
return $process;
|
||||
}
|
||||
|
||||
public function test_exports_skip_idle_containers_and_can_read_while_upstream_writer_holds_lock(): void
|
||||
{
|
||||
$directory = sys_get_temp_dir() . '/caiwu-export-batch-' . bin2hex(random_bytes(6));
|
||||
mkdir($directory . '/storage/app/account-bills-exports/1', 0777, true);
|
||||
$lock = fopen($directory . '/sync.lock', 'c');
|
||||
flock($lock, LOCK_EX);
|
||||
$task = $directory . '/storage/app/account-bills-exports/1/task.json';
|
||||
try {
|
||||
$this->assertTrue($this->runBatch($directory, ['account-bills:exports:run'])->isSuccessful());
|
||||
$this->assertFileDoesNotExist($directory . '/called');
|
||||
file_put_contents($task, json_encode(['status' => 'queued']));
|
||||
$this->assertTrue($this->runBatch($directory, ['account-bills:exports:run'])->isSuccessful());
|
||||
$this->assertFileExists($directory . '/called');
|
||||
} finally {
|
||||
fclose($lock);
|
||||
if (is_file($task)) unlink($task);
|
||||
foreach (['/storage/app/account-bills-exports/1', '/storage/app/account-bills-exports', '/storage/app', '/storage'] as $path) rmdir($directory . $path);
|
||||
foreach (glob($directory . '/*') as $path) unlink($path);
|
||||
rmdir($directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
tests/Unit/EcommerceFinance/AccountBillsExportQueueTest.php
Normal file
46
tests/Unit/EcommerceFinance/AccountBillsExportQueueTest.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\EcommerceFinance\Services\AccountBillsExportTasks;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AccountBillsExportQueueTest extends TestCase
|
||||
{
|
||||
public function test_contention_retains_queued_tasks_and_one_run_only_consumes_one(): void
|
||||
{
|
||||
config(['cache.stores.upstream_shared' => ['driver' => 'array'], 'upstream_pipeline.batch_worker_enabled' => true]);
|
||||
$directory = sys_get_temp_dir() . '/caiwu-export-queue-' . bin2hex(random_bytes(6));
|
||||
$tasks = new class($directory) extends AccountBillsExportTasks {
|
||||
public function __construct(private string $directory) {}
|
||||
public function rootDir(): string { return $this->directory; }
|
||||
};
|
||||
$this->app->instance(AccountBillsExportTasks::class, $tasks);
|
||||
$a = $tasks->create(11, 'test', '测试', [], [['field' => 'test', 'label' => '测试']], 0);
|
||||
$b = $tasks->create(11, 'test', '测试', [], [['field' => 'test', 'label' => '测试']], 0);
|
||||
$a = $tasks->patch($a, ['planned_at' => '2026-10-01 00:00:00', 'cancel' => true]);
|
||||
$b = $tasks->patch($b, ['planned_at' => '2026-10-02 00:00:00', 'cancel' => true]);
|
||||
$lock = Cache::store('upstream_shared')->lock('account-bills:export-worker', 10800);
|
||||
try {
|
||||
$tasks->dispatch($a);
|
||||
$this->assertSame('queued', $tasks->find(11, $a['token'])['status']);
|
||||
$this->assertTrue($lock->get());
|
||||
$this->artisan('account-bills:exports:run')->assertExitCode(0);
|
||||
$this->assertSame('queued', $tasks->find(11, $a['token'])['status']);
|
||||
$this->assertSame('queued', $tasks->find(11, $b['token'])['status']);
|
||||
$lock->release();
|
||||
$this->artisan('account-bills:exports:run')->assertExitCode(0);
|
||||
$this->assertSame('cancelled', $tasks->find(11, $a['token'])['status']);
|
||||
$this->assertSame('queued', $tasks->find(11, $b['token'])['status']);
|
||||
$this->artisan('account-bills:exports:run')->assertExitCode(0);
|
||||
$this->assertSame('cancelled', $tasks->find(11, $b['token'])['status']);
|
||||
$this->assertNull($tasks->nextQueued());
|
||||
} finally {
|
||||
$lock->release();
|
||||
foreach (glob($directory . '/11/*') ?: [] as $file) unlink($file);
|
||||
rmdir($directory . '/11');
|
||||
rmdir($directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use App\Console\Commands\RunAccountMappingResync;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AccountMappingResyncBudgetTest extends TestCase
|
||||
{
|
||||
public function test_default_budget_yields_after_one_job_and_retains_edits_received_during_sync(): void
|
||||
{
|
||||
config(['cache.stores.upstream_shared' => ['driver' => 'array']]);
|
||||
$table = AccountMappingResyncDispatcher::JOBS_TABLE;
|
||||
Schema::dropIfExists($table);
|
||||
Schema::create($table, function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('platform');
|
||||
$table->string('month');
|
||||
$table->string('status');
|
||||
$table->unsignedInteger('attempts')->default(0);
|
||||
$table->text('message')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
foreach (['2026-09', '2026-10'] as $month) {
|
||||
DB::table($table)->insert(['platform' => 'jingxi', 'month' => $month, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
$command = new class extends RunAccountMappingResync {
|
||||
public bool $edited = false;
|
||||
protected function runSync(object $job): array
|
||||
{
|
||||
if ($this->edited) {
|
||||
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)->where('id', $job->id)->update(['status' => 'pending']);
|
||||
}
|
||||
return [true, 'OK'];
|
||||
}
|
||||
};
|
||||
$this->app->make(Kernel::class)->registerCommand($command);
|
||||
$this->artisan('store-receipt:auto-resync')->assertSuccessful();
|
||||
$this->assertSame(['done', 'pending'], DB::table($table)->orderBy('id')->pluck('status')->all());
|
||||
$command->edited = true;
|
||||
$this->artisan('store-receipt:auto-resync')->assertSuccessful();
|
||||
$this->assertSame(['done', 'pending'], DB::table($table)->orderBy('id')->pluck('status')->all());
|
||||
$this->assertSame([1, 1], DB::table($table)->orderBy('id')->pluck('attempts')->all());
|
||||
}
|
||||
}
|
||||
@ -3,11 +3,75 @@
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingStore;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use ReflectionClass;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AccountMappingResyncDispatcherTest extends TestCase
|
||||
{
|
||||
public function test_identical_and_display_only_changes_do_not_require_receipt_rebuilds(): void
|
||||
{
|
||||
$rule = ['platform' => '京东', 'item' => '测试货款', 'memo' => '收入', 'basis' => '收入', 'feeMemo' => '说明'];
|
||||
$this->assertFalse(AccountMappingStore::requiresReceiptResync($rule, $rule));
|
||||
$this->assertFalse(AccountMappingStore::requiresReceiptResync($rule, array_replace($rule, ['feeMemo' => '更新说明'])));
|
||||
$this->assertTrue(AccountMappingStore::requiresReceiptResync($rule, array_replace($rule, ['memo' => '退款'])));
|
||||
$this->assertTrue(AccountMappingStore::requiresReceiptResync($rule, array_replace($rule, ['platform' => '抖音'])));
|
||||
}
|
||||
|
||||
public function test_only_existing_platform_month_pairs_are_enqueued(): void
|
||||
{
|
||||
config(['upstream_pipeline.batch_worker_enabled' => true]);
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->dropIfExists('store_receipt_order_match_rows');
|
||||
$schema->create('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->string('platform');
|
||||
$table->date('report_month');
|
||||
});
|
||||
if (! Schema::hasTable(AccountMappingResyncDispatcher::JOBS_TABLE)) {
|
||||
Schema::create(AccountMappingResyncDispatcher::JOBS_TABLE, function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('platform');
|
||||
$table->string('month');
|
||||
$table->string('status');
|
||||
$table->text('message')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)->delete();
|
||||
DB::connection('business_mirror')->table('store_receipt_order_match_rows')->insert([
|
||||
['platform' => '淘系支付宝', 'report_month' => '2026-09-01'],
|
||||
['platform' => '淘系微信', 'report_month' => '2026-10-01'],
|
||||
]);
|
||||
$this->assertSame(['jobs' => 2], app(AccountMappingResyncDispatcher::class)->dispatchFor(['淘宝']));
|
||||
$this->assertSame(['2026-09'], DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)->where('platform', 'taobao-alipay')->pluck('month')->all());
|
||||
$this->assertSame(['2026-10'], DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)->where('platform', 'taobao-wechat')->pluck('month')->all());
|
||||
}
|
||||
|
||||
public function test_reimporting_unchanged_rules_does_not_invalidate_history(): void
|
||||
{
|
||||
$originalStorage = $this->app->storagePath();
|
||||
$directory = sys_get_temp_dir() . '/mapping-resync-' . bin2hex(random_bytes(6));
|
||||
\Illuminate\Support\Facades\File::makeDirectory($directory . '/app', 0777, true);
|
||||
$this->app->useStoragePath($directory);
|
||||
try {
|
||||
$store = app(AccountMappingStore::class);
|
||||
$store->reset();
|
||||
$rows = [['platform' => '京东', 'item' => '测试资源优化项目', 'memo' => '收入']];
|
||||
$this->assertSame(['京东'], $store->importRows($rows, true)['resync_platforms']);
|
||||
$same = $store->importRows($rows, true);
|
||||
$this->assertSame(0, $same['updated']);
|
||||
$this->assertSame([], $same['resync_platforms']);
|
||||
$this->assertSame([], $store->importRows([array_replace($rows[0], ['feeMemo' => '更新说明'])], true)['resync_platforms']);
|
||||
$this->assertSame(['京东'], $store->importRows([array_replace($rows[0], ['basis' => AccountMappingStore::BASIS_IN])], true)['resync_platforms']);
|
||||
} finally {
|
||||
$this->app->useStoragePath($originalStorage);
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($directory);
|
||||
(new \ReflectionProperty(AccountMappingStore::class, 'allMemo'))->setValue(null);
|
||||
}
|
||||
}
|
||||
public function test_production_leaves_queue_processing_to_the_limited_batch_worker(): void
|
||||
{
|
||||
config(['upstream_pipeline.batch_worker_enabled' => true]);
|
||||
|
||||
@ -4,6 +4,7 @@ namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
@ -145,6 +146,37 @@ class BusinessMirrorSynchronizerTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_key_first_incremental_read_preserves_filters_projection_and_resume_keys(): void
|
||||
{
|
||||
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => '旧值']]];
|
||||
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
|
||||
config(['upstream_mirrors.table_sync.key_first_datasets' => ['test.dbo.records']]);
|
||||
$this->query->calls = [];
|
||||
$this->query->batches = [
|
||||
[['Id' => 1, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => '更新']],
|
||||
[['Id' => 2, 'updateTime' => '2026-08-15 10:01:00', 'displayName' => '新增']],
|
||||
[],
|
||||
];
|
||||
|
||||
$this->assertSame('running', $this->synchronizer->sync('test.dbo.records', false, 1, 1)['status']);
|
||||
$this->assertSame('succeeded', $this->synchronizer->sync('test.dbo.records', false, 1, 3)['status']);
|
||||
$sql = $this->query->calls[0]['sql'];
|
||||
$this->assertStringContainsString('FROM (SELECT TOP (1) [Id] FROM [dbo].[records] WHERE ([updateTime] >= ? AND [updateTime] < ?) ORDER BY [Id]) AS [mirror_keys]', $sql);
|
||||
$this->assertStringContainsString('INNER LOOP JOIN [dbo].[records] AS [mirror_rows] ON [mirror_rows].[Id] = [mirror_keys].[Id]', $sql);
|
||||
$this->assertStringContainsString('CONVERT(varchar(27), [mirror_rows].[updateTime], 126) AS [updateTime]', $sql);
|
||||
$this->assertStringContainsString('ORDER BY [mirror_keys].[Id]', $sql);
|
||||
$this->assertCount(2, $this->query->calls[0]['bindings']);
|
||||
$this->assertSame(1, $this->query->calls[1]['bindings'][2]);
|
||||
UpstreamReadOnlyQuery::assertReadOnlySql($sql);
|
||||
$this->assertSame(2, DB::connection('business_mirror')->table('test_records')->count());
|
||||
$this->assertSame('更新', DB::connection('business_mirror')->table('test_records')->where('Id', 1)->value('displayName'));
|
||||
|
||||
$this->query->calls = [];
|
||||
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => '更新']]];
|
||||
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
|
||||
$this->assertStringNotContainsString('LOOP JOIN', $this->query->calls[0]['sql']);
|
||||
}
|
||||
|
||||
public function test_fixed_filters_are_validated_and_bound_to_every_source_batch(): void
|
||||
{
|
||||
DB::table('upstream_schema_columns')->insert($this->column('channel', 5, 'nvarchar', 50));
|
||||
@ -507,6 +539,71 @@ class BusinessMirrorSynchronizerTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_upstream_timeout_keeps_previous_data_and_logs_only_safe_diagnostics(): void
|
||||
{
|
||||
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => '保留']]];
|
||||
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
|
||||
$previousSuccess = DB::connection('business_mirror')->table('business_mirror_table_states')->value('last_succeeded_at');
|
||||
$exception = new \PDOException('secret DSN and financial SQL must never be logged');
|
||||
$exception->errorInfo = ['HY000', 20003, 'secret native message'];
|
||||
$this->query->batches = [$exception];
|
||||
Log::spy();
|
||||
|
||||
try {
|
||||
$this->synchronizer->sync('test.dbo.records', false, 10, 1);
|
||||
$this->fail('Expected the upstream failure to propagate.');
|
||||
} catch (\PDOException $caught) {
|
||||
$this->assertSame($exception, $caught);
|
||||
}
|
||||
|
||||
Log::shouldHaveReceived('warning')->once()->withArgs(function (string $message, array $context): bool {
|
||||
$this->assertSame('read_upstream', $context['stage']);
|
||||
$this->assertSame('HY000', $context['sql_state']);
|
||||
$this->assertSame(20003, $context['driver_code']);
|
||||
$this->assertStringNotContainsString('secret', json_encode($context));
|
||||
$this->assertArrayNotHasKey('message', $context);
|
||||
$this->assertArrayNotHasKey('sql', $context);
|
||||
return true;
|
||||
});
|
||||
$state = DB::connection('business_mirror')->table('business_mirror_table_states')->first();
|
||||
$this->assertSame('failed', $state->status);
|
||||
$this->assertSame($previousSuccess, $state->last_succeeded_at);
|
||||
$this->assertSame('保留', DB::connection('business_mirror')->table('test_records')->value('displayName'));
|
||||
}
|
||||
|
||||
public function test_local_pdo_failure_is_identified_as_a_mirror_write(): void
|
||||
{
|
||||
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-14 10:00:00', 'displayName' => '保留']]];
|
||||
$this->synchronizer->sync('test.dbo.records', true, 10, 1);
|
||||
$exception = new \PDOException('private local database error');
|
||||
$exception->errorInfo = ['40001', 1213, 'private message'];
|
||||
$mirror = \Mockery::mock(GenericBusinessMirrorRepository::class, [app('db')])->makePartial();
|
||||
$mirror->shouldReceive('storeBatch')->once()->andThrow($exception);
|
||||
$factory = new class($this->query) extends UpstreamReadOnlyQueryFactory {
|
||||
public function __construct(private readonly UpstreamReadOnlyQuery $query) {}
|
||||
public function forSource(string $source): UpstreamReadOnlyQuery { return $this->query; }
|
||||
};
|
||||
$synchronizer = new BusinessMirrorSynchronizer(new BusinessMirrorDatasetRegistry(), new BusinessMirrorSchemaManager(), $mirror, $factory);
|
||||
$this->query->batches = [[['Id' => 1, 'updateTime' => '2026-08-15 10:00:00', 'displayName' => '未写入']]];
|
||||
Log::spy();
|
||||
|
||||
try {
|
||||
$synchronizer->sync('test.dbo.records', false, 10, 1);
|
||||
$this->fail('Expected the local failure to propagate.');
|
||||
} catch (\PDOException $caught) {
|
||||
$this->assertSame($exception, $caught);
|
||||
}
|
||||
|
||||
Log::shouldHaveReceived('warning')->once()->withArgs(function (string $message, array $context): bool {
|
||||
$this->assertSame('write_mirror', $context['stage']);
|
||||
$this->assertSame('40001', $context['sql_state']);
|
||||
$this->assertSame(1213, $context['driver_code']);
|
||||
$this->assertStringNotContainsString('private', json_encode($context));
|
||||
return true;
|
||||
});
|
||||
$this->assertSame('保留', DB::connection('business_mirror')->table('test_records')->value('displayName'));
|
||||
}
|
||||
|
||||
public function test_project_dataset_registry_has_unique_safe_targets(): void
|
||||
{
|
||||
$configured = require base_path('config/upstream_mirrors.php');
|
||||
@ -561,7 +658,7 @@ class BusinessMirrorSynchronizerTest extends TestCase
|
||||
|
||||
class FakeBusinessMirrorQuery extends UpstreamReadOnlyQuery
|
||||
{
|
||||
/** @var list<list<array<string,mixed>>> */
|
||||
/** @var list<list<array<string,mixed>>|\Throwable> */
|
||||
public array $batches = [];
|
||||
|
||||
/** @var list<array{sql:string,bindings:array}> */
|
||||
@ -576,6 +673,11 @@ class FakeBusinessMirrorQuery extends UpstreamReadOnlyQuery
|
||||
{
|
||||
$this->calls[] = compact('sql', 'bindings');
|
||||
|
||||
return array_shift($this->batches) ?? [];
|
||||
$batch = array_shift($this->batches) ?? [];
|
||||
if ($batch instanceof \Throwable) {
|
||||
throw $batch;
|
||||
}
|
||||
|
||||
return $batch;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,10 @@ namespace Tests\Unit\EcommerceFinance;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase
|
||||
@ -165,6 +168,220 @@ class CourierSurchargeSettlementMirrorRepositoryTest extends TestCase
|
||||
$this->assertStringContainsString('不一致', $result[4]['reason']);
|
||||
}
|
||||
|
||||
public function test_import_derives_courier_and_amount_from_overlength_quote(): void
|
||||
{
|
||||
$this->createTable();
|
||||
$this->seedSettlementPlaceholder();
|
||||
$this->createSupportTables();
|
||||
$this->seedQuotes([
|
||||
['id' => 6, 'courier' => '曹县德邦', 'types' => ['超长费'], 'mode' => '按尺寸', 'condition' => '单边超160cm', 'standard' => '8元/单'],
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ops_jst_sku')->insert([
|
||||
['sku_id' => 'SKU-1', 'l' => 194, 'w' => 83, 'h' => 12.5, 'weight' => 2],
|
||||
]);
|
||||
$this->mockUpstream(static function (string $sql): array {
|
||||
if (str_contains($sql, 'jst_salesOutboundInfo')) {
|
||||
return [[
|
||||
'Id' => 1, 'io_id' => 9001, 'o_id' => 7001, 'l_id' => 'DPK9', 'so_id' => 'SO-9',
|
||||
'shop_name' => '拼多多-测试店', 'io_date' => '2026-10-10 10:00:00', 'logistics_company' => '曹县德邦',
|
||||
]];
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'jst_salesOutboundItem')) {
|
||||
return [['io_id' => 9001, 'sku_id' => 'SKU-1', 'i_id' => 'I-1', 'qty' => 1]];
|
||||
}
|
||||
|
||||
if (str_contains($sql, 'Jst_SendDataDetail_View')) {
|
||||
return [['io_id' => 9001, 'sku_id' => 'SKU-1', 'shop_i_id' => '993815011271']];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
$result = $this->repository->decideImportRows([
|
||||
['row_number' => 2, 'waybill' => 'DPK9', 'surcharge_type' => '超长费', 'courier_name' => ''],
|
||||
]);
|
||||
|
||||
$this->assertSame('正常结', $result[0]['decision']);
|
||||
$this->assertSame('曹县德邦', $result[0]['courier_name']);
|
||||
$this->assertSame(8.0, (float) $result[0]['settlement_amount']);
|
||||
$this->assertSame('已计算', $result[0]['settlement_status']);
|
||||
$this->assertSame(194.0, (float) $result[0]['length']);
|
||||
$this->assertSame('993815011271', $result[0]['platform_product_id']);
|
||||
$this->assertSame('拼多多-测试店', $result[0]['shop_name']);
|
||||
$this->assertStringContainsString('160', (string) $result[0]['evidence']);
|
||||
}
|
||||
|
||||
public function test_import_derives_courier_and_amount_from_deduct_quotes(): void
|
||||
{
|
||||
$this->createTable();
|
||||
$this->seedSettlementPlaceholder();
|
||||
$this->createSupportTables();
|
||||
$this->seedQuotes([
|
||||
['id' => 20, 'courier' => '曹县德邦小件', 'types' => ['退回费'], 'mode' => '固定单价', 'condition' => '查件扣单并且买家原因已退回', 'standard' => '30元/单'],
|
||||
['id' => 21, 'courier' => '曹县德邦小件', 'types' => ['拦截费'], 'mode' => '固定单价', 'condition' => '查件扣单', 'standard' => '20元/单'],
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ops_jst_sku')->insert([
|
||||
['sku_id' => 'SKU-2', 'l' => 0, 'w' => 0, 'h' => 0, 'weight' => 2.4],
|
||||
]);
|
||||
DB::connection('business_mirror')->table('js_jst_work_order_info')->insert([
|
||||
'Id' => 1,
|
||||
'title' => '查件',
|
||||
'businessFieldList' => json_encode([
|
||||
['fieldId' => 'lIdField', 'value' => 'DPK10'],
|
||||
['fieldId' => 'logisticsField', 'value' => '曹县德邦小件'],
|
||||
['fieldId' => 'o_id', 'value' => 7001],
|
||||
['fieldId' => 'orderField', 'value' => '260710-1'],
|
||||
['fieldId' => 'shopField', 'valueLabel' => '拼多多-测试店'],
|
||||
['fieldId' => 'orderSendTime', 'value' => '2026-10-10 09:00:00'],
|
||||
['fieldId' => 'SELECT_CASCADEField1413856620250813172526', 'value' => '扣单/买家-不想要'],
|
||||
['fieldId' => 'itemInfo', 'childFieldList' => [[
|
||||
['fieldId' => 'skuId', 'value' => 'SKU-2'],
|
||||
['fieldId' => 'qty', 'value' => 1],
|
||||
['fieldId' => 'platformItemId', 'value' => 'P-2'],
|
||||
['fieldId' => 'iid', 'value' => 'I-2'],
|
||||
]]],
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$this->mockUpstream(static function (string $sql): array {
|
||||
if (str_contains($sql, 'jst_returnAndRefundInfo')) {
|
||||
return [[
|
||||
'o_id' => 7001, 'l_id' => 'DPK10', 'sku_id' => 'SKU-2', 'qty' => 1, 'r_qty' => 1,
|
||||
'receive_date' => '2026-10-12 09:00:00',
|
||||
]];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
$result = $this->repository->decideImportRows([
|
||||
['row_number' => 2, 'waybill' => 'DPK10', 'surcharge_type' => '退回费', 'courier_name' => ''],
|
||||
['row_number' => 3, 'waybill' => 'DPK10', 'surcharge_type' => '拦截费', 'courier_name' => ''],
|
||||
]);
|
||||
|
||||
$this->assertSame('正常结', $result[0]['decision']);
|
||||
$this->assertSame('曹县德邦小件', $result[0]['courier_name']);
|
||||
$this->assertSame(30.0, (float) $result[0]['settlement_amount']);
|
||||
$this->assertSame(2.4, (float) $result[0]['settlement_weight']);
|
||||
$this->assertSame('扣单', $result[0]['inspection_type']);
|
||||
$this->assertSame('DPK10', $result[0]['return_waybill_no']);
|
||||
|
||||
$this->assertSame('正常结', $result[1]['decision']);
|
||||
$this->assertSame(20.0, (float) $result[1]['settlement_amount']);
|
||||
}
|
||||
|
||||
private function seedSettlementPlaceholder(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([
|
||||
'rule_id' => 1, 'surcharge_type' => '超长费', 'courier_name' => '占位快递',
|
||||
'waybill' => 'PLACEHOLDER', 'settlement_amount' => 0, 'settlement_status' => '已计算',
|
||||
'shipped_at' => '2026-10-01 00:00:00', 'pulled_at' => '2026-10-01 00:00:00',
|
||||
'created_at' => '2026-10-01 00:00:00', 'updated_at' => '2026-10-01 00:00:00',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createSupportTables(): void
|
||||
{
|
||||
$defaultSchema = Schema::connection((string) config('database.default'));
|
||||
|
||||
if (! $defaultSchema->hasTable('business_courier_surcharge_quotes')) {
|
||||
$defaultSchema->create('business_courier_surcharge_quotes', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('courier');
|
||||
$table->string('types');
|
||||
$table->string('mode');
|
||||
$table->string('condition');
|
||||
$table->string('standard');
|
||||
$table->string('status');
|
||||
});
|
||||
}
|
||||
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('ops_jst_sku', function (Blueprint $table): void {
|
||||
$table->string('sku_id');
|
||||
$table->decimal('l', 12, 2)->default(0);
|
||||
$table->decimal('w', 12, 2)->default(0);
|
||||
$table->decimal('h', 12, 2)->default(0);
|
||||
$table->decimal('weight', 12, 2)->default(0);
|
||||
});
|
||||
$schema->create('js_jst_work_order_info', function (Blueprint $table): void {
|
||||
$table->bigIncrements('Id');
|
||||
$table->string('title')->nullable();
|
||||
$table->text('businessFieldList')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rules */
|
||||
private function seedQuotes(array $rules): void
|
||||
{
|
||||
$table = DB::connection((string) config('database.default'))->table('business_courier_surcharge_quotes');
|
||||
$table->delete();
|
||||
$table->insert(
|
||||
array_map(static fn (array $rule): array => [
|
||||
'id' => $rule['id'],
|
||||
'company_id' => 1,
|
||||
'courier' => $rule['courier'],
|
||||
'scope' => '快递应付',
|
||||
'reconciler' => '测试',
|
||||
'types' => json_encode($rule['types'], JSON_UNESCAPED_UNICODE),
|
||||
'mode' => $rule['mode'],
|
||||
'condition' => $rule['condition'],
|
||||
'standard' => $rule['standard'],
|
||||
'effective_at' => '2026-01-01',
|
||||
'status' => 'active',
|
||||
], $rules),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param callable(string,array):list<array<string,mixed>> $handler */
|
||||
private function mockUpstream(callable $handler): void
|
||||
{
|
||||
$query = Mockery::mock(UpstreamReadOnlyQuery::class);
|
||||
$query->shouldReceive('select')->andReturnUsing(
|
||||
static fn (string $sql, array $bindings = []): array => $handler($sql, $bindings),
|
||||
);
|
||||
$factory = Mockery::mock(UpstreamReadOnlyQueryFactory::class);
|
||||
$factory->shouldReceive('forSource')->andReturn($query);
|
||||
$this->app->instance(UpstreamReadOnlyQueryFactory::class, $factory);
|
||||
}
|
||||
|
||||
public function test_completed_waybill_index_uses_keyed_lookup_and_preserves_work_order_context(): void
|
||||
{
|
||||
$this->createTable();
|
||||
DB::connection('business_mirror')->table(CourierSurchargeSettlementMirrorRepository::TABLE)->insert([
|
||||
'rule_id' => 1, 'surcharge_type' => '退回费', 'courier_name' => '测试快递', 'waybill' => 'UNRELATED',
|
||||
'settlement_amount' => 0, 'settlement_status' => '已计算', 'pulled_at' => now(), 'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create('js_jst_work_order_info', function (Blueprint $table): void {
|
||||
$table->char('_mirror_key', 64)->primary();
|
||||
$table->unsignedInteger('Id');
|
||||
$table->string('title');
|
||||
$table->text('businessFieldList');
|
||||
});
|
||||
config(['system_database.mode' => 'shared']);
|
||||
(require base_path('database/migrations/2026_10_19_170000_create_work_order_waybill_lookup.php'))->up();
|
||||
$fields = [
|
||||
['fieldId' => 'lIdField', 'value' => '001234'],
|
||||
['fieldId' => 'SELECT_CASCADEField1413856620250813172526', 'value' => '催件/未到'],
|
||||
['fieldId' => 'soIdField', 'value' => 'SO-TEST'],
|
||||
];
|
||||
$db = DB::connection('business_mirror');
|
||||
$db->table('js_jst_work_order_info')->insert([
|
||||
'_mirror_key' => str_repeat('a', 64), 'Id' => 10, 'title' => '查件', 'businessFieldList' => json_encode($fields),
|
||||
]);
|
||||
app(\Modules\EcommerceFinance\Services\WorkOrderWaybillLookup::class)->backfill(500, 1, 0);
|
||||
$db->enableQueryLog();
|
||||
$results = $this->repository->decideImportRows([
|
||||
['waybill' => '001234', 'surcharge_type' => '退回费'],
|
||||
]);
|
||||
$this->assertSame('催件', $results[0]['inspection_type']);
|
||||
$this->assertStringContainsString('查件', $results[0]['reason']);
|
||||
$queries = collect($db->getQueryLog())->pluck('query')->implode("\n");
|
||||
$this->assertStringContainsString('work_order_waybill_lookup', $queries);
|
||||
$this->assertStringNotContainsString('businessFieldList" like', $queries);
|
||||
}
|
||||
|
||||
private function createTable(): void
|
||||
{
|
||||
Schema::connection('business_mirror')->create(
|
||||
|
||||
@ -20,6 +20,8 @@ class JdDailyBillTest extends TestCase
|
||||
config(['database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]);
|
||||
DB::purge('business_mirror');
|
||||
(require database_path('migrations/2026_09_18_140000_create_jd_daily_bill_mirror_tables.php'))->up();
|
||||
config(['system_database.mode' => 'shared']);
|
||||
(require database_path('migrations/2026_10_19_181000_cover_jd_daily_bill_summary_filters.php'))->up();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
@ -90,6 +92,23 @@ class JdDailyBillTest extends TestCase
|
||||
app(JdDailyBillRepository::class)->paginate([], 'occurred_at', 'asc', 1, 10);
|
||||
}
|
||||
|
||||
public function test_paging_reuses_summary_and_options_but_reimport_invalidates_them(): void
|
||||
{
|
||||
$this->import([$this->row('123', '10.00'), $this->row('124', '2.00')], 2, '12.00', '0.00');
|
||||
$repo = app(JdDailyBillRepository::class);
|
||||
$repo->paginate([], 'occurred_at', 'asc', 1, 1);
|
||||
$repo->filterOptions();
|
||||
$db = DB::connection('business_mirror');
|
||||
$db->enableQueryLog();
|
||||
$this->assertSame(12.0, $repo->paginate([], 'occurred_at', 'asc', 2, 1)['summary']['income']);
|
||||
$repo->filterOptions();
|
||||
$queries = strtolower(collect($db->getQueryLog())->pluck('query')->implode("\n"));
|
||||
$this->assertStringNotContainsString('sum(income)', $queries);
|
||||
$this->assertStringNotContainsString('select distinct', $queries);
|
||||
$this->import([$this->row('123', '25.00')], 1, '25.00', '0.00');
|
||||
$this->assertSame(25.0, $repo->paginate([], 'occurred_at', 'asc', 1, 1)['summary']['income']);
|
||||
}
|
||||
|
||||
public function test_cutoff_validates_rows_and_refuses_to_shorten_existing_coverage(): void
|
||||
{
|
||||
$row = array_replace($this->row('123', '10.00'), ['费用结算时间' => '2026-08-21 23:59:59']);
|
||||
|
||||
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceObservationRepository;
|
||||
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer as Differ;
|
||||
use Tests\TestCase;
|
||||
|
||||
class JushuitanCostPriceSnapshotDifferTest extends TestCase
|
||||
{
|
||||
private Differ $differ;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''],
|
||||
'upstream_mirrors.connection' => 'business_mirror', 'cache.default' => 'array']);
|
||||
DB::purge('business_mirror');
|
||||
Carbon::setTestNow('2026-10-20 12:00:00');
|
||||
$schema = Schema::connection('business_mirror');
|
||||
$schema->create(Differ::STATES, function (Blueprint $t): void {
|
||||
$t->string('dataset_key')->primary();
|
||||
foreach (['status', 'source_key', 'source_object', 'target_table', 'run_mode', 'generation', 'last_error_code'] as $c) $t->string($c)->nullable();
|
||||
foreach (['cursor', 'high_watermark'] as $c) $t->json($c)->nullable();
|
||||
foreach (['window_started_at', 'window_ends_at', 'initial_completed_at', 'last_attempted_at', 'last_succeeded_at'] as $c) $t->dateTime($c, 6)->nullable();
|
||||
$t->timestamps();
|
||||
});
|
||||
$schema->create('ops_jst_sku', function (Blueprint $t): void {
|
||||
$t->char('_mirror_key', 64)->primary(); $t->string('sku_type'); $t->string('sku_id');
|
||||
$t->decimal('cost_price', 20, 4)->nullable(); $t->dateTime('modified')->nullable(); $t->dateTime('_mirror_synced_at', 6);
|
||||
});
|
||||
$schema->create(Differ::SNAPSHOTS, function (Blueprint $t): void {
|
||||
$t->char('source_key', 64)->primary(); $t->string('sku_id'); $t->decimal('cost_price', 20, 4); $t->dateTime('captured_at', 6);
|
||||
});
|
||||
$schema->create(Differ::OBSERVATIONS, function (Blueprint $t): void {
|
||||
$t->char('id', 64)->primary(); $t->char('source_key', 64); $t->string('sku_id');
|
||||
$t->decimal('before_price', 20, 4); $t->decimal('after_price', 20, 4);
|
||||
$t->dateTime('previous_observed_at', 6); $t->dateTime('observed_at', 6); $t->dateTime('source_modified_at', 6)->nullable();
|
||||
});
|
||||
DB::connection('business_mirror')->table(Differ::STATES)->insert([
|
||||
'dataset_key' => ProductCatalogMirrorRepository::DATASET_KEY, 'status' => 'succeeded',
|
||||
'initial_completed_at' => '2026-10-19 00:00:00', 'last_succeeded_at' => '2026-10-20 11:59:00',
|
||||
]);
|
||||
$this->differ = new Differ(new GenericBusinessMirrorRepository(app('db')), new BusinessMirrorDatasetRegistry());
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_baseline_is_resumable_and_does_not_invent_history_then_observes_at_capture_time(): void
|
||||
{
|
||||
$db = DB::connection('business_mirror');
|
||||
$this->product('a', 'SKU-A', 12);
|
||||
$this->product('b', 'SKU-B', 20);
|
||||
$this->assertSame('running', $this->differ->sync(1, 1)['status']);
|
||||
$db->table(Differ::STATES)->where('dataset_key', Differ::DATASET_KEY)->update(['status' => 'failed']);
|
||||
$this->assertSame('succeeded', $this->differ->sync(1, 3)['status']);
|
||||
$this->assertSame(2, $db->table(Differ::SNAPSHOTS)->count());
|
||||
$this->assertSame(0, $db->table(Differ::OBSERVATIONS)->count());
|
||||
|
||||
Carbon::setTestNow('2026-10-20 12:10:00');
|
||||
$db->table('ops_jst_sku')->where('_mirror_key', 'a')->update(['cost_price' => 15.1234, '_mirror_synced_at' => '2026-10-20 12:09:00']);
|
||||
$result = $this->differ->sync(10, 2);
|
||||
$this->assertSame(1, $result['changes']);
|
||||
$event = $db->table(Differ::OBSERVATIONS)->first();
|
||||
$this->assertSame('2026-10-20 12:10:00.000000', $event->observed_at);
|
||||
$this->assertSame('2026-01-01 00:00:00', $event->source_modified_at);
|
||||
$this->assertEquals(12, $event->before_price);
|
||||
$this->assertEquals(15.1234, $event->after_price);
|
||||
$this->assertSame(0, $this->differ->sync(10, 2)['changes']);
|
||||
$this->assertSame(1, $db->table(Differ::OBSERVATIONS)->count());
|
||||
$this->assertFalse(Schema::connection('business_mirror')->hasTable('jushuitan_cost_price_history_rows'));
|
||||
$this->assertFalse(Schema::connection('business_mirror')->hasTable('jushuitan_cost_price_history_sync_states'));
|
||||
|
||||
$rows = [(object) ['_mirror_key' => 'a'], (object) ['_mirror_key' => 'b']];
|
||||
(new JushuitanCostPriceObservationRepository(new GenericBusinessMirrorRepository(app('db'))))->attach($rows);
|
||||
$this->assertCount(1, $rows[0]->jushuitan_cost_price_observations);
|
||||
$this->assertCount(0, $rows[1]->jushuitan_cost_price_observations);
|
||||
$this->assertStringContainsString('不是真实改价时间', $rows[0]->jushuitan_cost_price_observations[0]['remark']);
|
||||
}
|
||||
|
||||
public function test_new_products_null_prices_and_same_sku_on_different_source_rows_do_not_invent_changes(): void
|
||||
{
|
||||
$this->product('a', 'SAME', 12);
|
||||
$this->product('b', 'SAME', 25);
|
||||
$this->product('c', 'NULL', null);
|
||||
$this->differ->sync();
|
||||
Carbon::setTestNow('2026-10-20 12:10:00');
|
||||
$this->product('d', 'NEW', 99, '2026-10-20 12:09:00');
|
||||
$this->assertSame(0, $this->differ->sync()['changes']);
|
||||
$this->assertSame(3, DB::connection('business_mirror')->table(Differ::SNAPSHOTS)->count());
|
||||
DB::connection('business_mirror')->table(Differ::STATES)->where('dataset_key', ProductCatalogMirrorRepository::DATASET_KEY)->update(['status' => 'running']);
|
||||
$this->assertSame('source_not_ready', $this->differ->sync()['status']);
|
||||
}
|
||||
|
||||
private function product(string $key, string $sku, ?float $price, string $at = '2026-10-20 11:50:00'): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('ops_jst_sku')->insert([
|
||||
'_mirror_key' => $key, 'sku_id' => $sku, 'sku_type' => 'normal', 'cost_price' => $price,
|
||||
'modified' => '2026-01-01 00:00:00', '_mirror_synced_at' => $at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -136,6 +136,21 @@ class OrderDataMirrorRepositoryTest extends TestCase
|
||||
|
||||
$none = $repository->afterSales(array_replace($base, ['shop_names' => ['没有这个店铺']]), 1, 25);
|
||||
$this->assertSame([], $none['rows']);
|
||||
\Illuminate\Support\Facades\Cache::put(OrderDataMirrorRepository::SHOP_NAMES_CACHE_KEY, ['拼多多-甲店', '京东-乙店', '抖音-丙店'], 300);
|
||||
$db->enableQueryLog();
|
||||
$selected = $repository->afterSales(array_replace($base, ['shop_names' => ['拼多多-甲店'], 'shop_names_exact' => true]), 1, 25);
|
||||
$this->assertSame(['拼多多-甲店'], $this->sortedShops($selected['rows']));
|
||||
$this->assertDoesNotMatchRegularExpression('/shop_name\s+LIKE/i', collect($db->getQueryLog())->pluck('query')->implode("\n"));
|
||||
|
||||
Cache::forget(OrderDataMirrorRepository::SHOP_NAMES_CACHE_KEY);
|
||||
$db->table('ops_jst_shop_info')->insert(['shop_id' => 1, 'shop_name' => '拼多多-甲店']);
|
||||
$db->flushQueryLog();
|
||||
$selected = $repository->afterSales(array_replace($base, ['shop_names' => ['拼多多-甲店'], 'shop_names_exact' => true]), 1, 25);
|
||||
$this->assertSame(['拼多多-甲店'], $this->sortedShops($selected['rows']));
|
||||
$this->assertDoesNotMatchRegularExpression('/shop_name\s+LIKE/i', collect($db->getQueryLog())->pluck('query')->implode("\n"));
|
||||
// Array inputs containing partial names still retain the original contains semantics.
|
||||
$partial = $repository->afterSales(array_replace($base, ['shop_names' => ['甲店'], 'shop_names_exact' => true]), 1, 25);
|
||||
$this->assertSame(['拼多多-甲店'], $this->sortedShops($partial['rows']));
|
||||
}
|
||||
|
||||
public function test_shop_names_lists_distinct_shops_from_the_order_mirror(): void
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use App\Http\Controllers\Business\Pages;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PerformanceDifferenceRemarkTest extends TestCase
|
||||
{
|
||||
private function remark(array $attributes): ?string
|
||||
{
|
||||
$row = (object) array_merge([
|
||||
'platform' => '拼多多',
|
||||
'match_status' => '已匹配',
|
||||
'income_amount' => 0,
|
||||
'discount_amount' => 0,
|
||||
'offset_amount' => 0,
|
||||
'refund_amount' => 0,
|
||||
'product_amount' => 0,
|
||||
'paid_amount' => 0,
|
||||
'difference' => 0,
|
||||
'planting_amount' => null,
|
||||
'remark' => null,
|
||||
], $attributes);
|
||||
|
||||
return (new ReflectionMethod(Pages::class, 'receiptDifferenceRemark'))->invoke(null, $row);
|
||||
}
|
||||
|
||||
public function test_pinduoduo_full_refund_wins_before_other_difference_rules(): void
|
||||
{
|
||||
$this->assertSame('订单实付含已退款部分', $this->remark([
|
||||
'income_amount' => 7.00,
|
||||
'discount_amount' => 17.00,
|
||||
'refund_amount' => -7.00,
|
||||
'product_amount' => 42.00,
|
||||
'paid_amount' => 6.99,
|
||||
'difference' => 17.01,
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_pinduoduo_partial_refund_marks_platform_payment_discount(): void
|
||||
{
|
||||
$this->assertSame('差额=平台支付立减', $this->remark([
|
||||
'income_amount' => 896.00,
|
||||
'refund_amount' => -552.36,
|
||||
'product_amount' => 936.00,
|
||||
'paid_amount' => 894.21,
|
||||
'difference' => 1.79,
|
||||
]));
|
||||
|
||||
$this->assertSame('差额=平台支付立减', $this->remark([
|
||||
'income_amount' => 16.43,
|
||||
'refund_amount' => -12.00,
|
||||
'product_amount' => 21.90,
|
||||
'paid_amount' => 16.42,
|
||||
'difference' => 0.01,
|
||||
]));
|
||||
}
|
||||
|
||||
public function test_large_unexplained_pinduoduo_difference_stays_pending(): void
|
||||
{
|
||||
$this->assertSame('差额待核实', $this->remark([
|
||||
'income_amount' => 130.00,
|
||||
'refund_amount' => -50.00,
|
||||
'product_amount' => 150.00,
|
||||
'paid_amount' => 100.00,
|
||||
'difference' => 30.00,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@ -148,8 +148,13 @@ class PlantingLossCalculatorTest extends TestCase
|
||||
$this->assertTrue(Calculator::isFreeGoods('XN001', '信誉订单虚拟条码'));
|
||||
$this->assertTrue(Calculator::isFreeGoods('GG003-SZ', '透明挂钩3个'));
|
||||
$this->assertFalse(Calculator::isFreeGoods('DNZ-01', '儿童学习椅'));
|
||||
$this->assertSame(12060, Calculator::deductionCents('100.5', 1));
|
||||
$this->assertSame(24120, Calculator::deductionCents('100.5', 2));
|
||||
$this->assertTrue(Calculator::isFreeGoods('tcxj', '书柜 晒图有奖励(无实物)'));
|
||||
$this->assertTrue(Calculator::isVirtualOrderItem('X00006', ''));
|
||||
$this->assertTrue(Calculator::isVirtualOrderItem('GG003-SZ', '透明挂钩3个'));
|
||||
$this->assertTrue(Calculator::isVirtualOrderItem('GG-005', '挂钩'));
|
||||
$this->assertFalse(Calculator::isVirtualOrderItem('DNZ-01', '儿童学习椅'));
|
||||
$this->assertSame(10050, Calculator::deductionCents('100.5', 1));
|
||||
$this->assertSame(20100, Calculator::deductionCents('100.5', 2));
|
||||
}
|
||||
|
||||
public function test_loss_rows_match_shipments_and_split_exempt_from_deduct(): void
|
||||
@ -167,10 +172,14 @@ class PlantingLossCalculatorTest extends TestCase
|
||||
|
||||
$this->workOrder(10, 'GD-VIRTUAL', '5555', '2026-09-20 10:00:00');
|
||||
$this->workOrder(11, 'GD-NOCOST', '6666', '2026-09-21 10:00:00');
|
||||
$this->workOrder(12, 'GD-MERGED', '7777', '2026-09-22 10:00:00');
|
||||
$this->workOrder(13, 'GD-HOOK', '8888', '2026-09-23 10:00:00');
|
||||
// 未匹配到发货但备注拍A发B:同样列入不扣款(发运营)。
|
||||
$this->workOrder(14, 'GD-PAB-NOSHIP', '9090', '2026-09-24 10:00:00', [], '拍A发B');
|
||||
|
||||
// 一单两个包裹:学习椅 ×2 + 透明挂钩(挂钩不扣)。
|
||||
// 一单两个包裹:学习椅 ×2 + 无实物商品(不计货值)。
|
||||
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT001', 40.6, 'DNZ-01', '儿童学习椅', 2);
|
||||
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT002', 10, 'GG003-SZ', '透明挂钩3个');
|
||||
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT002', 10, 'XS-01', '晒图有奖励(无实物)');
|
||||
$this->ship('3604408018648198', '2026-09-30 23:59:59');
|
||||
$this->ship('260901-000000000000001', '2026-09-01 08:00:00');
|
||||
$this->ship('9999', '2026-07-31 23:59:59'); // 早于上月 1 日
|
||||
@ -178,26 +187,39 @@ class PlantingLossCalculatorTest extends TestCase
|
||||
$this->ship('5555', '2026-09-10 08:00:00', 'YT555', 0, 'XN-001', '信誉订单虚拟条码');
|
||||
$this->ship('5555', '2026-09-10 08:00:00', 'YT555', 0, 'GG003-SZ', '透明挂钩3个');
|
||||
$this->ship('6666', '2026-09-10 08:00:00', 'YT666', 0, 'ZERO', '无成本商品');
|
||||
// 合并发货:同一交易单号下有信誉订单虚拟条码 + 别的订单的实物,整单不扣。
|
||||
$this->ship('7777', '2026-09-11 08:00:00', 'YT777', 0, 'X00006', '信誉订单虚拟条码');
|
||||
$this->ship('7777', '2026-09-11 08:00:00', 'YT777', 0, 'DNZ-01', '儿童学习椅');
|
||||
// 实物 + 透明挂钩:整单不扣。
|
||||
$this->ship('8888', '2026-09-12 08:00:00', 'YT888', 0, 'DNZ-01', '儿童学习椅');
|
||||
$this->ship('8888', '2026-09-12 08:00:00', 'YT888', 0, 'GG003-SZ', '透明挂钩3个');
|
||||
$this->ship('6666', '2026-09-10 08:00:00', 'YT666', 0, 'DNZ-02', '组合商品');
|
||||
|
||||
$rows = collect($this->app->make(Calculator::class)->rows('2026-09'))->keyBy('work_order_no');
|
||||
|
||||
$this->assertSame(['GD-DEDUCT', 'GD-EXEMPT', 'GD-NOCOST', 'GD-SELLER', 'GD-UNMATCHED', 'GD-VIRTUAL'], $rows->keys()->sort()->values()->all());
|
||||
$this->assertSame(['GD-DEDUCT', 'GD-EXEMPT', 'GD-HOOK', 'GD-MERGED', 'GD-NOCOST', 'GD-PAB-NOSHIP', 'GD-SELLER', 'GD-UNMATCHED', 'GD-VIRTUAL'], $rows->keys()->sort()->values()->all());
|
||||
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-DEDUCT']['loss_result']);
|
||||
$this->assertSame("YT001\nYT002", $rows['GD-DEDUCT']['express_no']);
|
||||
$this->assertSame('50.60', $rows['GD-DEDUCT']['shipped_amount']);
|
||||
$this->assertSame('3', $rows['GD-DEDUCT']['shipped_qty']);
|
||||
// 取最新一条普通商品成本价 100.5:100.5 × 2 × 1.2 = 241.20,挂钩不计。
|
||||
$this->assertSame('241.20', $rows['GD-DEDUCT']['deduction_amount']);
|
||||
$this->assertSame('DNZ-01 × 2 × 100.5 × 1.2 = 241.20', $rows['GD-DEDUCT']['deduction_detail']);
|
||||
// 取最新一条普通商品成本价 100.5:100.5 × 2 = 201.00,无实物不计货值;加该单运费 3.20 × 2 个包裹 = 6.40。
|
||||
$this->assertSame('207.40', $rows['GD-DEDUCT']['deduction_amount']);
|
||||
$this->assertSame("DNZ-01 × 2 × 100.5 = 201.00\n运费 6.40", $rows['GD-DEDUCT']['deduction_detail']);
|
||||
$this->assertFalse($rows['GD-DEDUCT']['cost_missing']);
|
||||
|
||||
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-VIRTUAL']['loss_result']);
|
||||
$this->assertNull($rows['GD-VIRTUAL']['deduction_amount']);
|
||||
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-MERGED']['loss_result']);
|
||||
$this->assertNull($rows['GD-MERGED']['deduction_amount']);
|
||||
$this->assertStringContainsString('信誉订单虚拟条码', $rows['GD-MERGED']['loss_basis']);
|
||||
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-HOOK']['loss_result']);
|
||||
$this->assertNull($rows['GD-HOOK']['deduction_amount']);
|
||||
$this->assertSame(Calculator::RESULT_EXEMPT, $rows['GD-PAB-NOSHIP']['loss_result']);
|
||||
$this->assertSame('备注含“拍A发B”(未匹配到发货)', $rows['GD-PAB-NOSHIP']['loss_basis']);
|
||||
|
||||
// 成本价为 0、或只有组合装资料的条码视为成本价缺失。
|
||||
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-NOCOST']['loss_result']);
|
||||
$this->assertSame('0.00', $rows['GD-NOCOST']['deduction_amount']);
|
||||
$this->assertSame('6.40', $rows['GD-NOCOST']['deduction_amount']);
|
||||
$this->assertTrue($rows['GD-NOCOST']['cost_missing']);
|
||||
$this->assertStringContainsString('ZERO、DNZ-02', $rows['GD-NOCOST']['loss_basis']);
|
||||
$this->assertNull($rows['GD-EXEMPT']['deduction_amount']);
|
||||
|
||||
226
tests/Unit/EcommerceFinance/StoreReceiptMemoryTest.php
Normal file
226
tests/Unit/EcommerceFinance/StoreReceiptMemoryTest.php
Normal file
@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use App\Console\Commands\SyncStoreReceiptOrderMatches;
|
||||
use Illuminate\Database\Connectors\ConnectionFactory;
|
||||
use Illuminate\Database\MySqlConnection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
class StoreReceiptMemoryTest extends TestCase
|
||||
{
|
||||
private function bill(string $item, string $order, float $income, float $expense = 0, array $extra = []): object
|
||||
{
|
||||
return (object) array_merge([
|
||||
'store_name' => '支付宝-测试账户', 'online_real_store' => '天猫_测试店',
|
||||
'bill_item' => $item, 'online_order_no' => $order,
|
||||
'occurred_at' => '2026-08-01 10:00:00', 'income_amount' => $income, 'expense_amount' => $expense,
|
||||
], $extra);
|
||||
}
|
||||
|
||||
private function aggregate(iterable $rows, array $planting = [], ?AccountLedgerRemarkMap $map = null): array
|
||||
{
|
||||
$map ??= new class extends AccountLedgerRemarkMap {
|
||||
public function remark(?string $storeName, ?string $tabPlatform, ?string $billItem, array $businessRemarks = [], string $goodsName = ''): ?string
|
||||
{
|
||||
return $billItem;
|
||||
}
|
||||
};
|
||||
|
||||
return (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke(
|
||||
new SyncStoreReceiptOrderMatches(), $rows, ['label' => '淘系支付宝'], 'online_order_no', 'occurred_at',
|
||||
'income_amount', 'expense_amount', 'online_real_store', $planting, [], [], $map, '淘宝',
|
||||
);
|
||||
}
|
||||
|
||||
public function test_one_pass_keeps_payments_promotions_refunds_and_first_bill_metadata(): void
|
||||
{
|
||||
$bills = [
|
||||
$this->bill('优惠收入', '260630-10001', 0, 3),
|
||||
$this->bill('订单收入', '260630-10001', 100, 0, ['occurred_at' => '2026-08-02 10:00:00']),
|
||||
$this->bill('订单收入', '260630-10001', 25),
|
||||
$this->bill('冲减收入', '260630-10001', 0, 2),
|
||||
$this->bill('退款', '260630-10001', 0, 7),
|
||||
$this->bill('优惠收入', '20002', 4),
|
||||
$this->bill('退款', '20002', 0, 1),
|
||||
$this->bill('冲减收入', '', 0, 8),
|
||||
$this->bill('冲减收入', '', 3),
|
||||
$this->bill('退款', '', 0, 2),
|
||||
$this->bill('订单收入', '30003', 0),
|
||||
$this->bill('不计入', '40004', 999),
|
||||
];
|
||||
// A generator cannot be rewound. Promotions precede their eventual payment.
|
||||
$result = $this->aggregate((function () use ($bills) { yield from $bills; })(), ['10001' => true]);
|
||||
$key = SyncStoreReceiptOrderMatches::normalizeShop('天猫_测试店');
|
||||
$this->assertCount(2, $result['groups']);
|
||||
$paid = $result['groups'][$key . '|260630-10001'];
|
||||
$this->assertSame(2, $paid['payment_count']);
|
||||
$this->assertSame(125.0, $paid['income_amount']);
|
||||
$this->assertSame(-3.0, $paid['discount_amount']);
|
||||
$this->assertSame(-2.0, $paid['offset_amount']);
|
||||
$this->assertSame(-7.0, $paid['refund_amount']);
|
||||
$this->assertTrue($paid['planting_seen']);
|
||||
$this->assertSame('260630-10001', $paid['online_order_no']);
|
||||
$this->assertSame('2026-08-01', $paid['occurred_date']);
|
||||
$this->assertSame('天猫_测试店', $paid['store_name']);
|
||||
$this->assertSame(0.0, $result['groups'][$key . '|30003']['income_amount']);
|
||||
$this->assertSame(3, $result['paymentTotal']);
|
||||
$this->assertSame(12, $result['billCount']);
|
||||
$this->assertSame(8, $result['moneyRemarkRows']);
|
||||
$this->assertSame(125.0, $result['incomeTotal']);
|
||||
$this->assertSame(1.0, $result['discountTotal']);
|
||||
$this->assertSame(-7.0, $result['offsetTotal']);
|
||||
$this->assertSame(-10.0, $result['refundTotal']);
|
||||
$this->assertSame(-3.0, $result['attachedDiscount']);
|
||||
$this->assertSame(-2.0, $result['attachedOffset']);
|
||||
$this->assertSame(3, $result['unattachedRows']);
|
||||
$this->assertSame([$key . '|20002', $key . '||冲减收入', $key . '||退款'], array_keys($result['promoOnlyGroups']));
|
||||
$this->assertSame(4.0, $result['promoOnlyGroups'][$key . '|20002']['discount_amount']);
|
||||
$this->assertSame(-1.0, $result['promoOnlyGroups'][$key . '|20002']['refund_amount']);
|
||||
$this->assertSame(-5.0, $result['promoOnlyGroups'][$key . '||冲减收入']['offset_amount']);
|
||||
$this->assertSame(-2.0, $result['promoOnlyGroups'][$key . '||退款']['refund_amount']);
|
||||
}
|
||||
|
||||
public function test_same_core_with_different_date_prefixes_stays_as_two_orders(): void
|
||||
{
|
||||
$result = $this->aggregate([
|
||||
$this->bill('订单收入', '260627-475634091573139', 13.91),
|
||||
$this->bill('订单收入', '260713-475634091573139', 29.51),
|
||||
]);
|
||||
|
||||
$shop = SyncStoreReceiptOrderMatches::normalizeShop('天猫_测试店');
|
||||
$this->assertCount(2, $result['groups']);
|
||||
$first = $result['groups'][$shop . '|260627-475634091573139'];
|
||||
$second = $result['groups'][$shop . '|260713-475634091573139'];
|
||||
$this->assertSame('475634091573139', $first['order_core_no']);
|
||||
$this->assertSame('475634091573139', $second['order_core_no']);
|
||||
$this->assertSame('260627-475634091573139', $first['order_identity']);
|
||||
$this->assertSame('260713-475634091573139', $second['order_identity']);
|
||||
$this->assertSame(13.91, $first['income_amount']);
|
||||
$this->assertSame(29.51, $second['income_amount']);
|
||||
}
|
||||
|
||||
public function test_order_side_exact_full_number_wins_and_ambiguous_core_fallback_never_sums(): void
|
||||
{
|
||||
$command = new SyncStoreReceiptOrderMatches();
|
||||
$method = new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'matchOrderSide');
|
||||
$shop = SyncStoreReceiptOrderMatches::normalizeShop('拼多多-欧意朗家具官方旗舰店');
|
||||
$core = '475634091573139';
|
||||
$first = '260627-475634091573139';
|
||||
$second = '260713-475634091573139';
|
||||
$amounts = [
|
||||
$shop . '|' . $first => 13.91,
|
||||
$shop . '|' . $second => 29.51,
|
||||
];
|
||||
$orderSeen = [
|
||||
$shop . '|' . $first => true,
|
||||
$shop . '|' . $second => true,
|
||||
];
|
||||
$identitiesByCore = [$core => [$shop => [$first => true, $second => true]]];
|
||||
$coresByCore = [$core => [$shop => true]];
|
||||
|
||||
$exact = $method->invoke(
|
||||
$command, $first, $core, $shop, $amounts, $orderSeen, [$core => true], $coresByCore, $identitiesByCore, '无金额'
|
||||
);
|
||||
$this->assertSame(['已匹配', 13.91, ''], $exact);
|
||||
|
||||
$ambiguous = $method->invoke(
|
||||
$command, $core, $core, $shop, $amounts, $orderSeen, [$core => true], $coresByCore, $identitiesByCore, '无金额'
|
||||
);
|
||||
$this->assertSame(['未匹配', null, '订单侧同号多条'], $ambiguous);
|
||||
}
|
||||
|
||||
public function test_empty_order_promotions_remain_separate_even_with_an_empty_order_payment(): void
|
||||
{
|
||||
$result = $this->aggregate([
|
||||
$this->bill('订单收入', '', 12),
|
||||
$this->bill('优惠收入', '', 2, 0, ['occurred_at' => '2026-08-03 10:00:00']),
|
||||
$this->bill('优惠收入', '50005', 3),
|
||||
$this->bill('优惠收入', '50005', 4, 0, ['online_real_store' => '另一家店']),
|
||||
]);
|
||||
$this->assertCount(1, $result['groups']);
|
||||
$this->assertCount(3, $result['promoOnlyGroups']);
|
||||
$first = reset($result['promoOnlyGroups']);
|
||||
$this->assertSame('2026-08-03', $first['occurred_date']);
|
||||
$this->assertSame(2.0, $first['discount_amount']);
|
||||
}
|
||||
|
||||
public function test_business_remark_and_goods_name_are_forwarded_once_per_bill(): void
|
||||
{
|
||||
$map = $this->createMock(AccountLedgerRemarkMap::class);
|
||||
$map->expects($this->once())->method('remark')->with(
|
||||
'支付宝-测试账户', '淘宝', '动态项目', ['活动优惠'], '商品关键词',
|
||||
)->willReturn('优惠收入');
|
||||
$result = $this->aggregate([$this->bill('动态项目', '', 1.5, 0, [
|
||||
'custom_remark' => '活动优惠', 'second_remark' => '活动优惠', 'goods_name' => '商品关键词',
|
||||
])], [], $map);
|
||||
$this->assertSame(1.5, $result['discountTotal']);
|
||||
$this->assertSame([], $result['groups']);
|
||||
$this->assertCount(1, $result['promoOnlyGroups']);
|
||||
$this->assertSame(0, $this->aggregate([])['billCount']);
|
||||
}
|
||||
|
||||
public function test_stream_uses_bound_filter_and_sql_limit_in_sqlite(): void
|
||||
{
|
||||
$db = DB::connection('business_mirror');
|
||||
$db->getSchemaBuilder()->create('receipt_memory_test', function (\Illuminate\Database\Schema\Blueprint $table): void {
|
||||
$table->integer('id');
|
||||
$table->string('name');
|
||||
});
|
||||
$db->table('receipt_memory_test')->insert([['id' => 1, 'name' => 'a'], ['id' => 2, 'name' => 'b'], ['id' => 3, 'name' => 'c']]);
|
||||
$query = $db->table('receipt_memory_test')->where('id', '>', 1)->orderBy('id')->limit(1);
|
||||
$rows = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'streamBillRows'))->invoke(new SyncStoreReceiptOrderMatches(), $query);
|
||||
$this->assertSame([2], array_map(static fn (object $row) => $row->id, iterator_to_array($rows)));
|
||||
}
|
||||
|
||||
public function test_other_source_columns_and_missing_optional_columns_keep_their_amounts(): void
|
||||
{
|
||||
$map = $this->createMock(AccountLedgerRemarkMap::class);
|
||||
$map->expects($this->once())->method('remark')->with('京东-测试店', '京东', '货款(收)', [], '')->willReturn('订单收入');
|
||||
$result = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'aggregateBills'))->invoke(
|
||||
new SyncStoreReceiptOrderMatches(), [(object) ['store_name' => '京东-测试店', 'order_no' => '70007',
|
||||
'bill_date' => '2026-08-09', 'income' => 12.5, 'bill_item' => '货款(收)']],
|
||||
['label' => '京东'], 'order_no', 'bill_date', 'income', '', '', [], [], [], $map, '京东',
|
||||
);
|
||||
$group = reset($result['groups']);
|
||||
$this->assertSame(12.5, $result['incomeTotal']);
|
||||
$this->assertSame('2026-08-09', $group['occurred_date']);
|
||||
$this->assertSame('京东-测试店', $group['store_name']);
|
||||
$this->assertSame([], $result['promoOnlyGroups']);
|
||||
}
|
||||
|
||||
public function test_mysql_stream_uses_separate_connection_and_closes_it_after_consumer_error(): void
|
||||
{
|
||||
$normal = $this->createMock(MySqlConnection::class);
|
||||
$normal->method('getConfig')->willReturn(['driver' => 'mysql', 'options' => [\PDO::ATTR_TIMEOUT => 3]]);
|
||||
$query = new \Illuminate\Database\Query\Builder($normal,
|
||||
new \Illuminate\Database\Query\Grammars\MySqlGrammar(), new \Illuminate\Database\Query\Processors\MySqlProcessor());
|
||||
$query->from('receipt_memory_test')->where('id', '>', 1)->limit(1);
|
||||
$stream = $this->createMock(MySqlConnection::class);
|
||||
$stream->expects($this->once())->method('cursor')->with($query->toSql(), [1])->willReturnCallback(function () {
|
||||
yield (object) ['id' => 2];
|
||||
});
|
||||
$stream->expects($this->once())->method('disconnect');
|
||||
$normal->expects($this->never())->method('setReadPdo');
|
||||
$normal->expects($this->never())->method('disconnect');
|
||||
$factory = $this->createMock(ConnectionFactory::class);
|
||||
$factory->expects($this->once())->method('make')->with($this->callback(function (array $config): bool {
|
||||
return $config['options'][\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY] === false && $config['options'][\PDO::ATTR_TIMEOUT] === 3;
|
||||
}), 'receipt_bill_stream')->willReturn($stream);
|
||||
$this->app->instance('db.factory', $factory);
|
||||
$rows = (new ReflectionMethod(SyncStoreReceiptOrderMatches::class, 'streamBillRows'))->invoke(new SyncStoreReceiptOrderMatches(), $query);
|
||||
try {
|
||||
foreach ($rows as $row) {
|
||||
$this->assertSame(2, $row->id);
|
||||
throw new \RuntimeException('consumer aborted');
|
||||
}
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->assertSame('consumer aborted', $exception->getMessage());
|
||||
} finally {
|
||||
unset($rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReportUpstreamRepository;
|
||||
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
|
||||
use Modules\EcommerceFinance\Services\StoreReportPaidSalesChanges;
|
||||
use Tests\TestCase;
|
||||
|
||||
class StoreReportPaidSalesIncrementalTest extends TestCase
|
||||
{
|
||||
private function setupSummary(): StoreReportMirrorSynchronizer
|
||||
{
|
||||
config(['database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]);
|
||||
DB::purge('business_mirror');
|
||||
config(['cache.stores.upstream_shared' => ['driver' => 'array'], 'system_database.mode' => 'shared']);
|
||||
$schema = Schema::connection('business_mirror');
|
||||
foreach (['store_report_import_states', 'store_report_paid_sales_rows', 'store_receipt_order_match_rows', StoreReportPaidSalesChanges::TABLE] as $table) $schema->dropIfExists($table);
|
||||
(require base_path('database/migrations/2026_09_01_000000_create_store_report_mirror_tables.php'))->up();
|
||||
$schema->table('store_report_import_states', function (Blueprint $table): void {
|
||||
$table->string('run_mode')->nullable();
|
||||
$table->timestamp('initial_completed_at')->nullable();
|
||||
});
|
||||
(require base_path('database/migrations/2026_10_16_234000_create_store_report_paid_sales_rows.php'))->up();
|
||||
$schema->create('store_receipt_order_match_rows', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('platform');
|
||||
$table->date('report_month');
|
||||
$table->date('occurred_date');
|
||||
$table->string('store_name');
|
||||
foreach (['income_amount', 'discount_amount', 'offset_amount', 'refund_amount'] as $column) $table->decimal($column, 20, 4)->nullable();
|
||||
});
|
||||
(require base_path('database/migrations/2026_10_19_171000_track_paid_sales_dirty_dates.php'))->up();
|
||||
DB::connection('business_mirror')->table('store_receipt_order_match_rows')->insert([
|
||||
['platform' => 'A', 'report_month' => '2026-10-01', 'occurred_date' => '2026-10-01', 'store_name' => '测试店', 'income_amount' => 100, 'discount_amount' => -5, 'offset_amount' => 0, 'refund_amount' => -10],
|
||||
['platform' => 'B', 'report_month' => '2026-10-01', 'occurred_date' => '2026-10-01', 'store_name' => '测试店', 'income_amount' => 20, 'discount_amount' => 0, 'offset_amount' => 0, 'refund_amount' => 0],
|
||||
['platform' => 'A', 'report_month' => '2026-09-01', 'occurred_date' => '2026-09-30', 'store_name' => '测试店', 'income_amount' => 40, 'discount_amount' => 0, 'offset_amount' => 0, 'refund_amount' => 0],
|
||||
]);
|
||||
return new StoreReportMirrorSynchronizer(\Mockery::mock(StoreReportUpstreamRepository::class), app('db'));
|
||||
}
|
||||
|
||||
public function test_amount_change_without_row_count_change_only_rebuilds_affected_days(): void
|
||||
{
|
||||
$sync = $this->setupSummary();
|
||||
$db = DB::connection('business_mirror');
|
||||
$this->assertSame('succeeded', $sync->syncPaidSales()['status']);
|
||||
$untouchedId = $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-09-30')->value('id');
|
||||
$changes = app(StoreReportPaidSalesChanges::class);
|
||||
$changes->markPeriod('2026-10', 'A');
|
||||
$db->table('store_receipt_order_match_rows')->where('id', 1)->update(['income_amount' => 120]);
|
||||
$this->assertTrue($sync->syncPaidSales()['changed']);
|
||||
$this->assertSame(125.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount'));
|
||||
$this->assertSame($untouchedId, $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-09-30')->value('id'));
|
||||
$db->enableQueryLog();
|
||||
$this->assertFalse($sync->syncPaidSales()['changed']);
|
||||
$this->assertSame([], collect($db->getQueryLog())->filter(fn ($query) => str_contains($query['query'], 'store_receipt_order_match_rows'))->all());
|
||||
}
|
||||
|
||||
public function test_date_moves_and_deletions_clear_old_totals_and_include_other_platforms(): void
|
||||
{
|
||||
$sync = $this->setupSummary();
|
||||
$db = DB::connection('business_mirror');
|
||||
$sync->syncPaidSales();
|
||||
$changes = app(StoreReportPaidSalesChanges::class);
|
||||
$changes->markPeriod('2026-10', 'A');
|
||||
$db->table('store_receipt_order_match_rows')->where('id', 1)->update(['occurred_date' => '2026-10-03']);
|
||||
$changes->markPeriod('2026-10', 'A');
|
||||
$sync->syncPaidSales();
|
||||
$this->assertSame(20.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount'));
|
||||
$this->assertSame(85.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-03')->value('paid_amount'));
|
||||
$changes->markPeriod('2026-10', 'B');
|
||||
$db->table('store_receipt_order_match_rows')->where('platform', 'B')->delete();
|
||||
$sync->syncPaidSales();
|
||||
$this->assertSame(0, $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->count());
|
||||
$this->assertSame(125.0, (float) $db->table('store_report_paid_sales_rows')->sum('paid_amount'));
|
||||
}
|
||||
|
||||
public function test_a_new_revision_is_not_lost_when_an_older_snapshot_is_acknowledged(): void
|
||||
{
|
||||
$this->setupSummary();
|
||||
$changes = app(StoreReportPaidSalesChanges::class);
|
||||
$changes->markDates(['2026-10-01']);
|
||||
$old = $changes->pending();
|
||||
$changes->markDates(['2026-10-01']);
|
||||
$changes->acknowledge($old);
|
||||
$this->assertCount(1, $changes->pending());
|
||||
}
|
||||
|
||||
public function test_active_writer_defers_summary_and_other_writers_without_consuming_changes(): void
|
||||
{
|
||||
$sync = $this->setupSummary();
|
||||
$sync->syncPaidSales();
|
||||
$changes = app(StoreReportPaidSalesChanges::class);
|
||||
$changes->markDates(['2026-10-01']);
|
||||
$lock = Cache::store('upstream_shared')->lock('store-report:paid-sales', 3600);
|
||||
$this->assertTrue($lock->get());
|
||||
try {
|
||||
$this->assertFalse($sync->syncPaidSales()['changed']);
|
||||
$this->assertCount(1, $changes->pending());
|
||||
$this->artisan('store-receipt:order-match:sync', ['month' => '2026-10'])->assertExitCode(75);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
$this->assertTrue($sync->syncPaidSales()['changed']);
|
||||
$this->assertSame([], $changes->pending());
|
||||
}
|
||||
|
||||
public function test_aggregation_failure_keeps_previous_totals_and_dirty_dates(): void
|
||||
{
|
||||
$sync = $this->setupSummary();
|
||||
$db = DB::connection('business_mirror');
|
||||
$sync->syncPaidSales();
|
||||
app(StoreReportPaidSalesChanges::class)->markDates(['2026-10-01']);
|
||||
$db->listen(function ($query): void {
|
||||
if (str_starts_with($query->sql, 'INSERT INTO store_report_paid_sales_rows')) throw new \RuntimeException('test_aggregate_failure');
|
||||
});
|
||||
$this->assertSame('failed', $sync->syncPaidSales()['status']);
|
||||
$this->assertSame(105.0, (float) $db->table('store_report_paid_sales_rows')->where('occurred_on', '2026-10-01')->value('paid_amount'));
|
||||
$this->assertCount(1, app(StoreReportPaidSalesChanges::class)->pending());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user