Compare commits
74 Commits
feature/li
...
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 | |||
|
|
e0abc6732b | ||
|
|
c44d267902 | ||
|
|
02d21ce9ac | ||
| eec820bfc5 | |||
| 424311a441 | |||
| 79f6937e75 | |||
| 1f4f6f48d4 | |||
| eb0f246765 | |||
| 9e199243b6 | |||
| 74906ac5f6 | |||
| ea479733ab | |||
| 566a3ef4db | |||
| 9f4f4f0cbb | |||
| cbbf32bb5c | |||
| 664fc05faf | |||
|
|
9b2edfe1de | ||
| 0cb970eeec | |||
|
|
5fdd3bba17 | ||
| 87788d87ff | |||
| 13d54d0c76 | |||
| b22eb61541 | |||
| efb6d9c992 | |||
| a3b36b1fb6 | |||
| 9d8133cc73 | |||
| 09b4ffcb8a | |||
| 68b16a95c0 | |||
| bcbb9e72af |
@ -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 使用默认生产入口运行脚本。首次通过本机窗口提供一次服务器密码后,加密保存在本人电脑供后续自动使用,不要求新人准备私钥、执行命令或每次重输密码。已有私钥认证仍兼容。
|
||||
|
||||
@ -63,6 +63,7 @@ class JstOrderLabelImporter
|
||||
->where('ordered_at', '>=', $start)
|
||||
->where('ordered_at', '<', $end)
|
||||
->delete();
|
||||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||||
}
|
||||
|
||||
$now = now();
|
||||
@ -71,9 +72,10 @@ class JstOrderLabelImporter
|
||||
$noLabelOrders = 0;
|
||||
$buffer = [];
|
||||
|
||||
$flush = function () use (&$buffer, &$rows, $target): void {
|
||||
$flush = function () use (&$buffer, &$rows, $target, $companyId): void {
|
||||
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
|
||||
$target->table('jst_order_labels')->insert($chunk);
|
||||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||||
$rows += count($chunk);
|
||||
}
|
||||
$buffer = [];
|
||||
@ -191,6 +193,7 @@ class JstOrderLabelImporter
|
||||
->where('ordered_at', '>=', $start)
|
||||
->where('ordered_at', '<', $end)
|
||||
->delete();
|
||||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||||
};
|
||||
$cleared = ! $replace;
|
||||
|
||||
@ -203,9 +206,10 @@ class JstOrderLabelImporter
|
||||
$orderSeen = [];
|
||||
$buffer = [];
|
||||
|
||||
$flush = function () use (&$buffer, &$rows, $target): void {
|
||||
$flush = function () use (&$buffer, &$rows, $target, $companyId): void {
|
||||
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
|
||||
$target->table('jst_order_labels')->insert($chunk);
|
||||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||||
$rows += count($chunk);
|
||||
}
|
||||
$buffer = [];
|
||||
|
||||
@ -82,6 +82,7 @@ class JstOrderLabelPlantingRules
|
||||
public function forget(int $companyId): void
|
||||
{
|
||||
Cache::forget($this->key($companyId));
|
||||
app(PlantingOrderLabelLookup::class)->forget($companyId);
|
||||
}
|
||||
|
||||
private function key(int $companyId): string
|
||||
|
||||
68
app/Business/PlantingOrderLabelLookup.php
Normal file
68
app/Business/PlantingOrderLabelLookup.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Reuse label classification across monthly/platform jobs; writers invalidate it. */
|
||||
class PlantingOrderLabelLookup
|
||||
{
|
||||
/** @param list<string> $positiveLabels @return list<string> */
|
||||
public function orderNumbers(int $companyId, array $positiveLabels): array
|
||||
{
|
||||
sort($positiveLabels, SORT_STRING);
|
||||
$rules = hash('sha256', json_encode($positiveLabels, JSON_THROW_ON_ERROR));
|
||||
$key = $this->key($companyId);
|
||||
$read = function () use ($key, $rules, $companyId): ?array {
|
||||
$cached = Cache::get($key);
|
||||
|
||||
return is_array($cached) && ($cached['rules'] ?? null) === $rules
|
||||
&& ($cached['revision'] ?? null) === $this->revision($companyId)
|
||||
? $cached['orders'] : null;
|
||||
};
|
||||
if (($orders = $read()) !== null) {
|
||||
return $orders;
|
||||
}
|
||||
|
||||
return Cache::lock($key . ':build', 300)->block(305, function () use ($companyId, $positiveLabels, $key, $rules, $read): array {
|
||||
if (($orders = $read()) !== null) {
|
||||
return $orders;
|
||||
}
|
||||
$revision = $this->revision($companyId);
|
||||
$orders = [];
|
||||
foreach (DB::table('jst_order_labels')->where('company_id', $companyId)
|
||||
->where(function ($query) use ($positiveLabels): void {
|
||||
if ($positiveLabels !== []) {
|
||||
$query->whereIn('label', $positiveLabels);
|
||||
}
|
||||
$query->orWhereRaw('FIND_IN_SET(?, item_code) > 0', [JstOrderLabelPlantingRules::PLANTING_ITEM_CODE]);
|
||||
})->select('order_no')->distinct()->cursor() as $row) {
|
||||
$orders[] = (string) $row->order_no;
|
||||
}
|
||||
// An import/rule change during the scan must not publish an old result.
|
||||
if ($this->revision($companyId) === $revision) {
|
||||
Cache::put($key, ['rules' => $rules, 'revision' => $revision, 'orders' => $orders], now()->addHours(24));
|
||||
}
|
||||
|
||||
return $orders;
|
||||
});
|
||||
}
|
||||
|
||||
public function forget(int $companyId): void
|
||||
{
|
||||
$key = $this->key($companyId);
|
||||
Cache::store('upstream_shared')->forever($key . ':revision', bin2hex(random_bytes(16)));
|
||||
Cache::forget($key);
|
||||
}
|
||||
|
||||
public function revision(int $companyId): ?string
|
||||
{
|
||||
return Cache::store('upstream_shared')->get($this->key($companyId) . ':revision');
|
||||
}
|
||||
|
||||
private function key(int $companyId): string
|
||||
{
|
||||
return 'planting-order-labels:v1:' . $companyId;
|
||||
}
|
||||
}
|
||||
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')));
|
||||
}
|
||||
}
|
||||
49
app/Console/Commands/ImportFinanceLedgerBase.php
Normal file
49
app/Console/Commands/ImportFinanceLedgerBase.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerBaseImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportFinanceLedgerBase extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:import-base
|
||||
{path : 《银行当前余额-*.xlsx》路径}
|
||||
{--opening-date= : 期初日期,默认读取配置}
|
||||
{--recalculate : 导入后立即重算每日流水}';
|
||||
|
||||
protected $description = '导入财务台账的账户、店铺、公司、期初余额(全量覆盖)';
|
||||
|
||||
public function handle(FinanceLedgerBaseImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
$this->option('opening-date') ?: null,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '数量/金额'], [
|
||||
['账户', $result['accounts']],
|
||||
['店铺', $result['shops']],
|
||||
['公司', $result['companies']],
|
||||
['期初账户', $result['opening_rows']],
|
||||
['期初合计', number_format($result['opening_sum'], 2)],
|
||||
['账户余额合计', number_format($result['account_balance_sum'], 2)],
|
||||
['期初日期', $result['opening_date']],
|
||||
]);
|
||||
|
||||
if ($this->option('recalculate')) {
|
||||
$this->call('finance-ledger:recalculate');
|
||||
}
|
||||
|
||||
$this->info('财务台账基础数据导入完成。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
42
app/Console/Commands/ImportFinanceLedgerSnapshot.php
Normal file
42
app/Console/Commands/ImportFinanceLedgerSnapshot.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerSnapshotImporter;
|
||||
use Throwable;
|
||||
|
||||
class ImportFinanceLedgerSnapshot extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:import-snapshot
|
||||
{path : 已核对的 ledger_raw.json 路径}
|
||||
{--no-recalculate : 只导入明细,不重算流水}';
|
||||
|
||||
protected $description = '导入财务台账动账明细快照(本地开发或首次建账使用)';
|
||||
|
||||
public function handle(FinanceLedgerSnapshotImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('path'),
|
||||
! $this->option('no-recalculate'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['明细行数', $result['rows']],
|
||||
['收入合计', number_format($result['income'], 2)],
|
||||
['支出合计', number_format($result['expense'], 2)],
|
||||
['日期范围', $result['from'] . ' ~ ' . $result['to']],
|
||||
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
|
||||
['期末余额合计', isset($result['recalculated']) ? number_format($result['recalculated']['balance_sum'], 2) : '-'],
|
||||
]);
|
||||
$this->info('财务台账动账明细导入完成。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
28
app/Console/Commands/ImportSupplierBookkeepingConfigs.php
Normal file
28
app/Console/Commands/ImportSupplierBookkeepingConfigs.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
|
||||
|
||||
class ImportSupplierBookkeepingConfigs extends Command
|
||||
{
|
||||
protected $signature = 'supplier-bookkeeping:import-configs {file} {--dry-run : 仅检查文件并输出条数,不写入}';
|
||||
protected $description = '导入财务提供的厂家记账配置 Excel(供应商、厂家是否记账);新增/更新/跳过,任一行无法识别整批不写入';
|
||||
|
||||
public function handle(SupplierBookkeepingConfigImporter $importer, SupplierBookkeepingConfigStore $store): int
|
||||
{
|
||||
$path = (string) $this->argument('file');
|
||||
$rows = $importer->read($path);
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('文件校验通过,共' . count($rows) . '条;未写入。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$result = $store->import($rows, '命令行导入', hash_file('sha256', $path));
|
||||
$this->info("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
35
app/Console/Commands/RecalculateFinanceLedger.php
Normal file
35
app/Console/Commands/RecalculateFinanceLedger.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
|
||||
use Throwable;
|
||||
|
||||
class RecalculateFinanceLedger extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:recalculate';
|
||||
|
||||
protected $description = '重算财务台账的每日资金流水、账户余额和店铺余额';
|
||||
|
||||
public function handle(FinanceLedgerCalculator $calculator): int
|
||||
{
|
||||
try {
|
||||
$result = $calculator->recalculate();
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['账户数', $result['accounts']],
|
||||
['流水行数', $result['flows']],
|
||||
['期末余额合计', number_format($result['balance_sum'], 2)],
|
||||
['期初日期', $result['opening_date']],
|
||||
]);
|
||||
$this->info('财务台账流水重算完成。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
112
app/Console/Commands/RunAccountMappingResync.php
Normal file
112
app/Console/Commands/RunAccountMappingResync.php
Normal file
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 科目映射表变更后的业绩明细自动重跑队列。
|
||||
*
|
||||
* 每轮按预算处理队列里的「平台 × 月份」;实际同步放在独立 PHP 子进程中,
|
||||
* 避免大平台重跑时的内存累积,并让其它同步有机会取得全局锁。
|
||||
*/
|
||||
class RunAccountMappingResync extends Command
|
||||
{
|
||||
protected $signature = 'store-receipt:auto-resync {--max-jobs=1 : 每轮最多任务数,0为不限} {--max-seconds=300 : 单个任务结束后检查本轮时间预算}';
|
||||
|
||||
protected $description = '处理科目映射表变更触发的业绩明细重跑队列';
|
||||
|
||||
public function handle(AccountMappingResyncDispatcher $dispatcher): int
|
||||
{
|
||||
// 页面(Apache)与每分钟 cron 都以 www-data 运行,使用共享数据库锁,
|
||||
// 不受 storage/framework/cache 文件属主影响,也能避免多容器重复消费。
|
||||
$lock = Cache::store('upstream_shared')->lock('store-receipt:auto-resync', 86400);
|
||||
if (! $lock->get()) {
|
||||
// 已有进程在跑;本次新入队的任务会在当前进程下一轮循环或每分钟兜底时执行。
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
// 进程崩溃遗留的 running 任务超过 60 分钟视为可重试。
|
||||
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
|
||||
->where('status', 'running')
|
||||
->where('updated_at', '<', now()->subMinutes(60))
|
||||
->update(['status' => 'pending', 'updated_at' => now()]);
|
||||
|
||||
$processed = 0;
|
||||
$maxJobs = max(0, (int) $this->option('max-jobs'));
|
||||
$deadline = microtime(true) + max(1, (int) $this->option('max-seconds'));
|
||||
while (($maxJobs === 0 || $processed < $maxJobs) && microtime(true) < $deadline) {
|
||||
$job = DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
|
||||
->where('status', 'pending')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
if ($job === null) {
|
||||
break;
|
||||
}
|
||||
$processed++;
|
||||
|
||||
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
|
||||
->where('id', $job->id)
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'running',
|
||||
'attempts' => DB::raw('attempts + 1'),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
try {
|
||||
[$succeeded, $output] = $this->runSync($job);
|
||||
|
||||
// 同步期间又保存了映射时,dispatchFor 已把状态改回 pending;
|
||||
// 这里只在仍为 running 时写 done,不能吞掉新请求。
|
||||
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
|
||||
->where('id', $job->id)
|
||||
->where('status', 'running')
|
||||
->update([
|
||||
'status' => $succeeded ? 'done' : 'failed',
|
||||
'message' => mb_substr(
|
||||
$output !== '' ? $output : ($succeeded ? 'OK' : '同步命令执行失败'),
|
||||
0,
|
||||
1000
|
||||
),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('科目映射表自动重跑失败:' . $exception->getMessage());
|
||||
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
|
||||
->where('id', $job->id)
|
||||
->where('status', 'running')
|
||||
->update([
|
||||
'status' => 'failed',
|
||||
'message' => mb_substr($exception->getMessage(), 0, 1000),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array{bool,string} */
|
||||
protected function runSync(object $job): array
|
||||
{
|
||||
$process = new Process([
|
||||
PHP_BINARY ?: 'php', 'artisan', 'store-receipt:order-match:sync',
|
||||
(string) $job->month, '--platform=' . $job->platform,
|
||||
], base_path());
|
||||
$process->setTimeout(7200);
|
||||
$process->run();
|
||||
|
||||
return [$process->isSuccessful(), trim($process->getOutput() . "\n" . $process->getErrorOutput())];
|
||||
}
|
||||
}
|
||||
49
app/Console/Commands/SyncFinanceLedgerRpt.php
Normal file
49
app/Console/Commands/SyncFinanceLedgerRpt.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerRptSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncFinanceLedgerRpt extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:sync-rpt
|
||||
{--from= : 业务日期起,默认读取配置}
|
||||
{--to= : 业务日期止,默认读取配置或当天}
|
||||
{--no-recalculate : 只同步明细,不重算流水}';
|
||||
|
||||
protected $description = '从 AI 库 rpt 同步财务台账动账明细,并重算每日流水';
|
||||
|
||||
public function handle(FinanceLedgerRptSynchronizer $synchronizer): int
|
||||
{
|
||||
try {
|
||||
$result = $synchronizer->sync(
|
||||
! $this->option('no-recalculate'),
|
||||
$this->option('from') ?: null,
|
||||
$this->option('to') ?: null,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['明细行数', $result['rows']],
|
||||
['收入合计', number_format($result['income'], 2)],
|
||||
['支出合计', number_format($result['expense'], 2)],
|
||||
['日期范围', $result['from'] . ' ~ ' . $result['to']],
|
||||
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
|
||||
['期末余额合计', isset($result['recalculated']) ? number_format($result['recalculated']['balance_sum'], 2) : '-'],
|
||||
]);
|
||||
|
||||
$this->line('来源分布:');
|
||||
foreach (($result['source_counts'] ?? []) as $source => $count) {
|
||||
$this->line(' ' . $source . ': ' . $count);
|
||||
}
|
||||
$this->info('财务台账 rpt 同步完成。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -142,7 +142,12 @@ class SyncJstOrderLabelItemCodes extends Command
|
||||
|
||||
$params = array_merge($bindings, [$companyId], $orderNos, [$start, $end]);
|
||||
|
||||
return DB::connection()->update($sql, $params);
|
||||
$updated = DB::connection()->update($sql, $params);
|
||||
if ($updated > 0) {
|
||||
app(\App\Business\PlantingOrderLabelLookup::class)->forget($companyId);
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} 起止日期(Y-m-d) */
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -166,20 +177,24 @@ class SyncStoreReceiptProductCost extends Command
|
||||
}
|
||||
$this->info('订单侧候选单号:' . count($candidates));
|
||||
|
||||
/** @var array<string,list<array{sku:string,qty:float,amount:float,warehouse:string}>> $lines */
|
||||
/** @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(
|
||||
"SELECT o.so_id as so_id, "
|
||||
. $source['warehouse'] . " as warehouse_code, "
|
||||
. " COALESCE(wh.name, '') as warehouse_name, "
|
||||
. " CASE WHEN o.link_o_id IS NULL OR o.link_o_id = 0 THEN 1 ELSE 0 END as is_main, "
|
||||
. " CONCAT(o.o_id, '-', i.oi_id) as row_key, "
|
||||
. " i.sku_id as sku_id, i.qty as qty, i.amount as amount
|
||||
FROM tmp_cost_order_nos t
|
||||
STRAIGHT_JOIN {$source['orders']} o ON o.so_id = t.no
|
||||
STRAIGHT_JOIN {$source['items']} i ON i.o_id = o.o_id
|
||||
LEFT JOIN ops_jst_warehouse_info wh ON wh.wms_co_id = o.wms_co_id
|
||||
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'"
|
||||
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());
|
||||
@ -210,13 +225,22 @@ class SyncStoreReceiptProductCost extends Command
|
||||
$warehouse = '';
|
||||
}
|
||||
}
|
||||
$lines[$core][] = [
|
||||
// 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。
|
||||
'sku' => mb_strtoupper($sku),
|
||||
'qty' => $qty,
|
||||
'amount' => $amount,
|
||||
'warehouse' => $warehouse,
|
||||
];
|
||||
// 组合装条码(ZH-条码-数量-条码-数量)按子订单拆成组件行:
|
||||
// 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。
|
||||
// 解析不了的组合条码(条码被截断等)保持原样,避免丢行。
|
||||
foreach (self::expandComboSku($sku, $qty) as [$code, $codeQty, $isComponent]) {
|
||||
$lines[$core][] = [
|
||||
// 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。
|
||||
'sku' => mb_strtoupper($code),
|
||||
'qty' => $codeQty,
|
||||
// 组合装明细金额对应整包,拆到组件后不再参与「数量×单价 vs 明细金额」校验。
|
||||
'amount' => $isComponent ? 0.0 : $amount,
|
||||
'warehouse' => $warehouse,
|
||||
// 拆单后子单(link_o_id 非空)才是实际发货行;主单只是原始下单记录。
|
||||
'is_main' => (int) $row->is_main === 1,
|
||||
'row_key' => (string) $row->row_key,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->info('订单侧命中核心单号:' . count($lines));
|
||||
@ -225,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);
|
||||
@ -243,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;
|
||||
// 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。
|
||||
@ -261,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);
|
||||
@ -331,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
|
||||
));
|
||||
@ -342,11 +378,15 @@ class SyncStoreReceiptProductCost extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* 拆单去重:聚水潭的拆单子单会把主单的商品行再列一遍,同一批商品只算一次;
|
||||
* 同一商品在同一订单里既有真实发货仓库行、又有无仓库的占位行时,丢掉占位行,
|
||||
* 这样「同一单分多仓发货」会按子单的实际仓库分行。
|
||||
* 拆单取数:聚水潭拆单后,主单(link_o_id 为空)保留的是原始下单行,
|
||||
* 子单(link_o_id 指向主单)才是实际发货行。一个订单只要出现子单,就只取子单,
|
||||
* 否则主单和子单会被各算一遍(主单的原始商品 + 子单的实际发货商品)。
|
||||
* 没有子单的订单才用主单行。
|
||||
*
|
||||
* @param list<array{sku:string,qty:float,amount:float,warehouse:string}> $rows
|
||||
* 同一订单的多条子单(不同仓库 / 分批发货)按「发货仓库 + 商品编码」汇总,数量相加,
|
||||
* 这样「一个订单有几个发货仓库、商品编码、数量」会分开列。
|
||||
*
|
||||
* @param list<array{sku:string,qty:float,amount:float,warehouse:string,is_main:bool,row_key:string}> $rows
|
||||
* @return list<array{sku:string,qty:float,amount:float,warehouse:string}>
|
||||
*/
|
||||
private function costLines(array $rows): array
|
||||
@ -355,25 +395,23 @@ class SyncStoreReceiptProductCost extends Command
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1) 完全相同的行(商品 + 数量 + 金额 + 仓库)只保留一条。
|
||||
// 1) 先按订单明细主键去掉上游的完全重复行(同一 o_id + oi_id 只算一次)。
|
||||
$unique = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = $row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount'] . "\0" . $row['warehouse'];
|
||||
$key = $row['row_key'] . "\0" . $row['sku'];
|
||||
$unique[$key] = $row;
|
||||
}
|
||||
|
||||
// 2) 有真实仓库的同一(商品 + 数量 + 金额),优先于无仓库的占位行。
|
||||
$withWarehouse = [];
|
||||
// 2) 有子单就只取子单(实际发货行);整单没有子单才用主单行。
|
||||
$hasSubOrder = false;
|
||||
foreach ($unique as $row) {
|
||||
if ($row['warehouse'] !== '') {
|
||||
$withWarehouse[$row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount']] = true;
|
||||
if (! $row['is_main']) {
|
||||
$hasSubOrder = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
foreach ($unique as $key => $row) {
|
||||
if ($row['warehouse'] === ''
|
||||
&& isset($withWarehouse[$row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount']])) {
|
||||
unset($unique[$key]);
|
||||
}
|
||||
if ($hasSubOrder) {
|
||||
$unique = array_filter($unique, static fn (array $row): bool => ! $row['is_main']);
|
||||
}
|
||||
|
||||
// 3) 汇总到「发货仓库 + 商品编码」。
|
||||
@ -391,6 +429,54 @@ class SyncStoreReceiptProductCost extends Command
|
||||
return array_values($aggregated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合装条码拆组件:聚水潭的组合装 sku 形如
|
||||
* ZH-<条码>-<数量>[-<条码>-<数量>...]
|
||||
* 条码本身含横杠,所以按「纯数字段」识别数量,再按组件展开。
|
||||
*
|
||||
* 返回 [条码, 数量, 是否组件];非组合装或解析失败时返回原条码 / 原数量。
|
||||
*
|
||||
* @return list<array{0:string,1:float,2:bool}>
|
||||
*/
|
||||
private static function expandComboSku(string $sku, float $quantity): array
|
||||
{
|
||||
if (! str_starts_with(mb_strtoupper($sku), 'ZH-')) {
|
||||
return [[$sku, $quantity, false]];
|
||||
}
|
||||
|
||||
$parts = explode('-', substr($sku, 3));
|
||||
$components = [];
|
||||
$buffer = [];
|
||||
foreach ($parts as $part) {
|
||||
if ($part === '') {
|
||||
return [[$sku, $quantity, false]];
|
||||
}
|
||||
if (preg_match('/^\d+(\.\d+)?$/', $part) === 1) {
|
||||
if ($buffer === []) {
|
||||
return [[$sku, $quantity, false]];
|
||||
}
|
||||
$components[] = [implode('-', $buffer), (float) $part];
|
||||
$buffer = [];
|
||||
continue;
|
||||
}
|
||||
$buffer[] = $part;
|
||||
}
|
||||
if ($buffer !== [] || $components === []) {
|
||||
return [[$sku, $quantity, false]];
|
||||
}
|
||||
|
||||
$expanded = [];
|
||||
foreach ($components as [$code, $multiplier]) {
|
||||
// 组合装里如果拆出套餐/服务占位编码,同样不取数。
|
||||
if (in_array(mb_strtoupper(trim($code)), self::EXCLUDED_SKUS, true)) {
|
||||
continue;
|
||||
}
|
||||
$expanded[] = [$code, $quantity * $multiplier, true];
|
||||
}
|
||||
|
||||
return $expanded === [] ? [] : $expanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仓库映射表:发货仓库 → 仓库简称。
|
||||
*
|
||||
@ -402,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);
|
||||
@ -411,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];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -532,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);
|
||||
|
||||
@ -7,7 +7,8 @@ use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 店铺维度报表的「销量」汇总:业绩明细「订单实付/实收金额」按「店铺名称 + 发生日期」预汇总。
|
||||
* 店铺维度报表的「销量」汇总:业绩明细「收入金额 + 优惠金额 + 冲减金额 + 退款金额」
|
||||
* 按「店铺名称 + 发生日期」预汇总。
|
||||
*
|
||||
* 业绩明细同步(store-receipt:order-match:sync)结束后会自动刷新一次;
|
||||
* 这个命令用于单独重跑(例如补数据或按最新代码强制重建)。
|
||||
@ -17,7 +18,7 @@ class SyncStoreReportPaidSales extends Command
|
||||
protected $signature = 'business-mirror:sync-store-report-paid-sales
|
||||
{--force : 忽略来源指纹,强制重建销量汇总}';
|
||||
|
||||
protected $description = '把业绩明细的订单实付/实收金额按店铺和发生日期汇总,供店铺维度报表销量列读取';
|
||||
protected $description = '把业绩明细的收入/优惠/冲减/退款金额按店铺和发生日期汇总,供店铺维度报表销量列读取';
|
||||
|
||||
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
|
||||
{
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
70
app/Console/Commands/VerifyFinanceLedger.php
Normal file
70
app/Console/Commands/VerifyFinanceLedger.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class VerifyFinanceLedger extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:verify';
|
||||
|
||||
protected $description = '校验财务台账数量、金额勾稽和流水平衡(只读)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$db = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$accounts = (int) $db->table('fin_accounts')->count();
|
||||
$shops = (int) $db->table('fin_shops')->count();
|
||||
$companies = (int) $db->table('fin_companies')->count();
|
||||
$openingRows = (int) $db->table('fin_account_opening')->count();
|
||||
$transactions = (int) $db->table('fin_transactions')->count();
|
||||
$flows = (int) $db->table('fin_daily_flows')->count();
|
||||
$maps = (int) $db->table('fin_account_map')->count();
|
||||
$mapped = (int) $db->table('fin_account_map')->whereNotNull('account_short')->where('account_short', '<>', '')->count();
|
||||
|
||||
$accountBalance = round((float) $db->table('fin_accounts')->sum('current_balance'), 2);
|
||||
$openingSum = round((float) $db->table('fin_account_opening')->sum('opening_balance'), 2);
|
||||
$income = round((float) $db->table('fin_transactions')->sum('income'), 2);
|
||||
$expense = round((float) $db->table('fin_transactions')->sum('expense'), 2);
|
||||
$flowOpening = round((float) $db->table('fin_daily_flows')->sum('opening_balance'), 2);
|
||||
$flowInflow = round((float) $db->table('fin_daily_flows')->sum('inflow'), 2);
|
||||
$flowOutflow = round((float) $db->table('fin_daily_flows')->sum('outflow'), 2);
|
||||
$flowClosing = round((float) $db->table('fin_daily_flows')->sum('closing_balance'), 2);
|
||||
$diff = round($flowOpening + $flowInflow - $flowOutflow - $flowClosing, 2);
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['账户', $accounts],
|
||||
['店铺', $shops],
|
||||
['公司', $companies],
|
||||
['期初账户', $openingRows],
|
||||
['期初合计', number_format($openingSum, 2)],
|
||||
['账户余额合计', number_format($accountBalance, 2)],
|
||||
['动账明细', $transactions],
|
||||
['收入合计', number_format($income, 2)],
|
||||
['支出合计', number_format($expense, 2)],
|
||||
['每日流水', $flows],
|
||||
['流水期末合计', number_format($flowClosing, 2)],
|
||||
['流水勾稽差异', number_format($diff, 2)],
|
||||
['映射记录', $maps],
|
||||
['已映射', $mapped],
|
||||
]);
|
||||
|
||||
$errors = [];
|
||||
if (abs($openingSum - $accountBalance) > 0.01) {
|
||||
$errors[] = '期初合计与账户余额合计不一致。';
|
||||
}
|
||||
if (abs($diff) > 0.01) {
|
||||
$errors[] = '每日流水期末不等于期初+入账-出账。';
|
||||
}
|
||||
if ($openingRows !== $flows) {
|
||||
$errors[] = '期初账户数与当前流水行数不一致(期初日应一账户一行)。';
|
||||
}
|
||||
|
||||
foreach ($errors as $error) {
|
||||
$this->error($error);
|
||||
}
|
||||
|
||||
return $errors === [] ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
@ -9,14 +9,14 @@ use Throwable;
|
||||
|
||||
class WarmOrderDataShopCandidates extends Command
|
||||
{
|
||||
protected $signature = 'order-data:warm-shop-candidates';
|
||||
protected $signature = 'order-data:warm-shop-candidates {--full : 全量核对店铺候选(夜间维护)}';
|
||||
|
||||
protected $description = '预热订单售后费用明细的店铺候选(要扫两张百万级订单宽表,不能放在页面请求里)';
|
||||
protected $description = '增量预热订单售后费用明细的店铺候选,夜间使用 --full 全量核对';
|
||||
|
||||
public function handle(OrderDataMirrorRepository $orders): int
|
||||
{
|
||||
try {
|
||||
$names = $orders->refreshShopNamesCache();
|
||||
$names = $orders->refreshShopNamesCache((bool) $this->option('full'));
|
||||
$this->info(sprintf('店铺候选已预热,共 %d 个店铺。', count($names)));
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
@ -76,6 +76,14 @@ class Kernel extends ConsoleKernel
|
||||
->withoutOverlapping(30)
|
||||
->runInBackground();
|
||||
|
||||
// 财务台账:从 AI 库 rpt 同步动账明细,按日重算账户流水。
|
||||
// 生产宿主机 cron 仍需配置对应的 finance-ledger:sync-rpt 入口。
|
||||
$schedule->command('finance-ledger:sync-rpt')
|
||||
->dailyAt('02:30')
|
||||
->timezone('Asia/Shanghai')
|
||||
->withoutOverlapping(120)
|
||||
->runInBackground();
|
||||
|
||||
$schedule->command('business-mirror:sync-third-party-warehouse-storage --max-batches=0')
|
||||
->dailyAt('00:00')
|
||||
->withoutOverlapping(180)
|
||||
@ -88,12 +96,13 @@ class Kernel extends ConsoleKernel
|
||||
->runInBackground();
|
||||
}
|
||||
|
||||
// 聚水潭发货数据每日重拉最近三天,覆盖副本库迟到更新。
|
||||
// 聚水潭发货数据:凌晨重拉最近 40 天。聚水潭常在发货后数天到数周内换单/改快递公司/作废采购退货出库,
|
||||
// 只拉最近几天会让上月数据停在旧值(快递名称不一致、已作废的单仍在);40 天覆盖上月结算期。
|
||||
// 默认关闭(JST_SYNC_ENABLED=false);填好凭证并完成迁移后再打开。
|
||||
if ((bool) env('JST_SYNC_ENABLED', false)) {
|
||||
$schedule->command('jushuitan:sync-shipping --days=3')
|
||||
$schedule->command('jushuitan:sync-shipping --days=40')
|
||||
->dailyAt('01:28')
|
||||
->withoutOverlapping(120)
|
||||
->withoutOverlapping(300)
|
||||
->runInBackground();
|
||||
// 凌晨副本库最近一两天的订单/商品明细常未到齐(视图、订单商品表滞后),
|
||||
// 早上再拉一次最近 3 天,补齐凌晨只写入单号、缺商品明细的包裹。
|
||||
|
||||
@ -157,7 +157,10 @@ class DingTalkLogin extends Controller
|
||||
|
||||
$url = $this->applicationRoute($user->landing_page, ['company_id' => $company->id]);
|
||||
|
||||
return redirect()->intended($url);
|
||||
// 免登后固定回到首页(财务驾驶舱),不再回跳上次打开的业务页面。
|
||||
$request->session()->forget('url.intended');
|
||||
|
||||
return redirect()->to($url);
|
||||
}
|
||||
|
||||
protected function failed(): RedirectResponse
|
||||
|
||||
@ -107,8 +107,11 @@ class Login extends Controller
|
||||
// Redirect to landing page if is user
|
||||
$url = route($user->landing_page, ['company_id' => $company->id]);
|
||||
|
||||
// 登录后固定回到首页(财务驾驶舱),不再回跳上次打开/被拦截的业务页面。
|
||||
$request->session()->forget('url.intended');
|
||||
|
||||
if (! $request->expectsJson()) {
|
||||
return redirect()->intended($url);
|
||||
return redirect()->to($url);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@ -117,7 +120,7 @@ class Login extends Controller
|
||||
'error' => false,
|
||||
'message' => trans('auth.login_redirect'),
|
||||
'data' => null,
|
||||
'redirect' => redirect()->intended($url)->getTargetUrl(),
|
||||
'redirect' => url($url),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ use Modules\EcommerceFinance\Services\AccountMappingCoa;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingStore;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingValidator;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingWorkbookImporter;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
@ -42,6 +43,7 @@ class AccountMappingActions extends Controller
|
||||
'memo' => '财务备注',
|
||||
'invoice' => '票据口径',
|
||||
'category' => '大类',
|
||||
'feeMemo' => '费用备注',
|
||||
'dAcct' => '借方科目',
|
||||
'dSub' => '借方二级',
|
||||
'cAcct' => '贷方科目',
|
||||
@ -74,27 +76,37 @@ class AccountMappingActions extends Controller
|
||||
|
||||
$this->store->create($values, $this->userId());
|
||||
|
||||
return response()->json(['message' => '已新增 1 条规则。'] + $this->payload());
|
||||
$hint = $this->dispatchResync([(string) ($values['platform'] ?? '')]);
|
||||
|
||||
return response()->json(['message' => '已新增 1 条规则。' . $hint] + $this->payload());
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
if ($this->store->find($id) === null) {
|
||||
$before = $this->store->find($id);
|
||||
if ($before === null) {
|
||||
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
|
||||
}
|
||||
|
||||
$this->store->update($id, $this->payloadValues($request), $this->userId());
|
||||
$values = $this->payloadValues($request);
|
||||
$after = $this->store->update($id, $values, $this->userId());
|
||||
|
||||
return response()->json(['message' => '已保存。'] + $this->payload());
|
||||
$hint = AccountMappingStore::requiresReceiptResync($before, $after)
|
||||
? $this->dispatchResync([(string) ($before['platform'] ?? ''), (string) ($after['platform'] ?? '')]) : '';
|
||||
|
||||
return response()->json(['message' => '已保存。' . $hint] + $this->payload());
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$before = $this->store->find($id);
|
||||
if (! $this->store->destroy($id, $this->userId())) {
|
||||
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
|
||||
}
|
||||
|
||||
return response()->json(['message' => '已删除 1 条规则。'] + $this->payload());
|
||||
$hint = $this->dispatchResync([(string) ($before['platform'] ?? '')]);
|
||||
|
||||
return response()->json(['message' => '已删除 1 条规则。' . $hint] + $this->payload());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -181,21 +193,24 @@ class AccountMappingActions extends Controller
|
||||
}
|
||||
|
||||
$restored = $this->store->create($before, $this->userId());
|
||||
$hint = $this->dispatchResync([(string) ($restored['platform'] ?? '')]);
|
||||
|
||||
return response()->json([
|
||||
'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。',
|
||||
'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。' . $hint,
|
||||
] + $this->payload());
|
||||
}
|
||||
|
||||
if ($action === 'update') {
|
||||
if ($this->store->find($ruleId) === null) {
|
||||
$current = $this->store->find($ruleId);
|
||||
if ($current === null) {
|
||||
throw ValidationException::withMessages(['history' => '这条规则已经被删掉了,请用同一条的「删除」记录恢复。']);
|
||||
}
|
||||
|
||||
$this->store->update($ruleId, $before, $this->userId());
|
||||
$hint = $this->dispatchResync([(string) ($current['platform'] ?? ''), (string) ($before['platform'] ?? '')]);
|
||||
|
||||
return response()->json([
|
||||
'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。',
|
||||
'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。' . $hint,
|
||||
] + $this->payload());
|
||||
}
|
||||
|
||||
@ -227,6 +242,8 @@ class AccountMappingActions extends Controller
|
||||
$parsed = $this->importer->parse($file);
|
||||
$result = $this->store->importRows($parsed['rows'], $request->boolean('overwrite'), $this->userId());
|
||||
$message = "导入完成:识别 {$result['total']} 行,新增 {$result['created']} 条,更新 {$result['updated']} 条,跳过重复 {$result['skipped']} 条。";
|
||||
$platforms = $result['resync_platforms'];
|
||||
$message .= $this->dispatchResync($platforms);
|
||||
|
||||
return response()->json(['message' => $message, 'result' => $result] + $this->payload());
|
||||
}
|
||||
@ -302,7 +319,7 @@ class AccountMappingActions extends Controller
|
||||
{
|
||||
$total = $this->store->reset($this->userId());
|
||||
|
||||
return response()->json(['message' => "已恢复为初始 {$total} 条规则。"] + $this->payload());
|
||||
return response()->json(['message' => "已恢复为初始 {$total} 条规则。" . $this->dispatchResyncAll()] + $this->payload());
|
||||
}
|
||||
|
||||
/** 账套科目(一级 + 各一级下的明细),供下拉使用 */
|
||||
@ -353,8 +370,8 @@ class AccountMappingActions extends Controller
|
||||
public function template(): StreamedResponse
|
||||
{
|
||||
$samples = [
|
||||
['得物', '平台服务费', '平台服务费', '0.06', '费用', '销售费用', '平台佣金', '其他货币资金', '', '支出基准'],
|
||||
['淘宝', '交易收入', '订单', '-', '收入', '其他货币资金', '', '主营业务收入', '', '收入基准'],
|
||||
['得物', '平台服务费', '平台服务费', '-', '0.06', '费用', '平台服务费', '销售费用', '平台服务费', '其他货币资金', '', '支出基准'],
|
||||
['淘宝', '交易收入', '订单', '-', '-', '收入', '收入', '其他货币资金', '', '主营业务收入', '', '收入基准'],
|
||||
];
|
||||
|
||||
return response()->streamDownload(function () use ($samples): void {
|
||||
@ -406,6 +423,44 @@ class AccountMappingActions extends Controller
|
||||
return user_id() ? (int) user_id() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存映射后把受影响的平台排进后台重跑队列。
|
||||
*
|
||||
* 映射表本身是实时读取的,但业绩明细的优惠/冲减/退款金额是同步时落库的,
|
||||
* 所以保存成功后需要自动重跑对应平台和月份。队列创建失败不能把已经保存成功的规则回滚。
|
||||
*
|
||||
* @param list<string> $platforms
|
||||
*/
|
||||
private function dispatchResync(array $platforms): string
|
||||
{
|
||||
try {
|
||||
$result = app(AccountMappingResyncDispatcher::class)->dispatchFor($platforms);
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return ' 业绩明细自动重跑任务创建失败,请稍后重试保存或联系管理员。';
|
||||
}
|
||||
|
||||
return $result['jobs'] > 0
|
||||
? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。"
|
||||
: '';
|
||||
}
|
||||
|
||||
private function dispatchResyncAll(): string
|
||||
{
|
||||
try {
|
||||
$result = app(AccountMappingResyncDispatcher::class)->dispatchAll();
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return ' 业绩明细自动重跑任务创建失败,请稍后重试或联系管理员。';
|
||||
}
|
||||
|
||||
return $result['jobs'] > 0
|
||||
? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。"
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 原始操作记录 + 页面/CSV 直接可用的字段(变更内容与操作人只在服务端算一次)。
|
||||
*
|
||||
|
||||
62
app/Http/Controllers/Business/FinanceLedger.php
Normal file
62
app/Http/Controllers/Business/FinanceLedger.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Routing\Controller;
|
||||
use App\Utilities\FinanceLedgerAccess;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
|
||||
|
||||
class FinanceLedger extends Controller
|
||||
{
|
||||
public function updateMapping(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改账户映射。');
|
||||
|
||||
$data = $request->validate([
|
||||
'account_short' => ['nullable', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$account = trim((string) ($data['account_short'] ?? ''));
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$row = $connection->table('fin_account_map')->where('id', $id)->first();
|
||||
abort_if($row === null, 404);
|
||||
|
||||
if ($account !== '') {
|
||||
$exists = $connection->table('fin_accounts')->where('account_short', $account)->exists();
|
||||
abort_unless($exists, 422, '账户简称不存在,请从账户总表中选择。');
|
||||
}
|
||||
|
||||
$old = (string) ($row->account_short ?? '');
|
||||
$connection->table('fin_account_map')->where('id', $id)->update([
|
||||
'account_short' => $account !== '' ? $account : null,
|
||||
'updated_by' => (string) user()->name,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$connection->table('fin_account_map_events')->insert([
|
||||
'source' => (string) $row->source,
|
||||
'source_value' => (string) $row->source_value,
|
||||
'old_account_short' => $old !== '' ? $old : null,
|
||||
'new_account_short' => $account !== '' ? $account : null,
|
||||
'user_id' => (int) user()->id,
|
||||
'user_name' => (string) user()->name,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
if ($request->boolean('recalculate', true)) {
|
||||
$calculator->recalculate();
|
||||
}
|
||||
|
||||
$mapped = $connection->table('fin_account_map')
|
||||
->whereNotNull('account_short')->where('account_short', '<>', '')->count();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'account_short' => $account,
|
||||
'mapped' => $mapped,
|
||||
'total' => $connection->table('fin_account_map')->count(),
|
||||
], 200, ['Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
}
|
||||
@ -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 = [
|
||||
'账单无单号',
|
||||
@ -247,28 +257,53 @@ class Pages extends Controller
|
||||
private static function receiptJingxiSupplyEvidenceSql(): array
|
||||
{
|
||||
$guard = "platform = '京喜账单' AND COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) > 0.005";
|
||||
// 京喜账单按「活动代」保留多代历史导入:只认与本月重叠的活动代(与同步命令同一口径)。
|
||||
// 不按代过滤时,旧代 + 新代会把同一笔直营服务费算两遍(例如 3,432 被算成 6,864),
|
||||
// 供货价规则整月对不上,全部落进「差额待核实」。没有活动代登记的月份保持原口径。
|
||||
$generation = "AND (
|
||||
NOT EXISTS (SELECT 1 FROM jd_daily_bill_periods p
|
||||
WHERE p.period_start < DATE_ADD(store_receipt_order_match_rows.report_month, INTERVAL 1 MONTH)
|
||||
AND p.period_end > store_receipt_order_match_rows.report_month)
|
||||
OR b.generation IN (SELECT p.active_generation FROM jd_daily_bill_periods p
|
||||
WHERE p.period_start < DATE_ADD(store_receipt_order_match_rows.report_month, INTERVAL 1 MONTH)
|
||||
AND p.period_end > store_receipt_order_match_rows.report_month)
|
||||
)";
|
||||
|
||||
return [
|
||||
'guard' => $guard,
|
||||
'supply_fee' => "(SELECT GREATEST(COALESCE(SUM(b.income), 0), COALESCE(SUM(b.expense), 0))
|
||||
FROM jd_daily_bill_rows b
|
||||
WHERE b.order_no = store_receipt_order_match_rows.online_order_no
|
||||
AND b.bill_item = '直营服务费')",
|
||||
AND b.bill_item = '直营服务费'
|
||||
{$generation})",
|
||||
'supply_net' => "(SELECT ABS(COALESCE(SUM(b.income), 0) - COALESCE(SUM(b.expense), 0))
|
||||
FROM jd_daily_bill_rows b
|
||||
WHERE b.order_no = store_receipt_order_match_rows.online_order_no
|
||||
AND b.bill_item = '直营服务费')",
|
||||
AND b.bill_item = '直营服务费'
|
||||
{$generation})",
|
||||
'gross_up_fee' => "(SELECT COALESCE(SUM(b.expense), 0)
|
||||
FROM jd_daily_bill_rows b
|
||||
WHERE b.order_no = store_receipt_order_match_rows.online_order_no
|
||||
AND b.bill_item = '直营服务费'
|
||||
{$generation})",
|
||||
];
|
||||
}
|
||||
|
||||
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'];
|
||||
@ -294,14 +329,29 @@ class Pages extends Controller
|
||||
. ' AND (ABS(' . $effective . ' - (' . $jingxiEvidence['supply_fee'] . ')) < 0.005'
|
||||
. ' OR ABS(' . $effective . ' - (' . $jingxiEvidence['supply_net'] . ')) < 0.005)) THEN 1 ELSE 0 END = 1';
|
||||
|
||||
// 京喜正差额:账单「货款(收)」把直营服务费一起计入收入(货款 = 商品金额 + 直营服务费),
|
||||
// 聚水潭「订单实付」是买家实付;差额 = 直营服务费 −(实付−商品金额)。
|
||||
// 宜自然 44 行逐单对平,全京喜同形态近千行(2026-10-19 核)。
|
||||
$jingxiGrossUp = "platform = '京喜账单' AND " . $effective . ' > 0.005'
|
||||
. ' AND COALESCE(product_amount, 0) > 0.005'
|
||||
. ' AND (' . $jingxiEvidence['gross_up_fee'] . ') > 0.005'
|
||||
. ' AND ABS((COALESCE(income_amount, 0) - COALESCE(product_amount, 0)) - (' . $jingxiEvidence['gross_up_fee'] . ')) < 0.02';
|
||||
$rules['差额=货款含直营服务费(实付低于商品金额)'] = $jingxiGrossUp
|
||||
. ' AND COALESCE(paid_amount, 0) < COALESCE(product_amount, 0) - 0.03';
|
||||
$rules['差额=货款含直营服务费(拆单/合并尾差)'] = $jingxiGrossUp;
|
||||
|
||||
// 京喜自营工厂店负差额:差额=京东「直营补贴款」(大店售价低于供货价的差价,见京麦费用项 50241),
|
||||
// 无退款的行走到结算单开票核销后收回;订单已退款的行走「取消退款」,补贴款已随退款冲回(无应收)。
|
||||
// 店铺范围:出类拔萃 + 9 家已逐店核验的京喜自营工厂店(见 JINGXI_SUBSIDY_STORE_PREFIXES)。
|
||||
// 店铺范围:① 出类拔萃 + 9 家已逐店核验的店铺(JINGXI_SUBSIDY_STORE_PREFIXES);
|
||||
// ② 其余京喜自营工厂店按「实付≈商品金额(供货价口径)」特征自动识别(含个别非「工厂店」命名的自营店),
|
||||
// 避免未提供后台账号的店铺/新店再次落进「差额待核实」。
|
||||
$jingxiSubsidyStoreLikes = implode(' OR ', array_map(
|
||||
static fn (string $prefix): string => "store_name LIKE '" . $prefix . "%'",
|
||||
self::JINGXI_SUBSIDY_STORE_PREFIXES
|
||||
));
|
||||
$jingxiSubsidyGuard = "platform = '京喜账单' AND (" . $jingxiSubsidyStoreLikes . ') AND ' . $effective . ' < -0.005';
|
||||
$jingxiSubsidyFactoryStores = "(store_name LIKE '京喜-%工厂店%' OR store_name LIKE '京喜-香幽竹%' OR store_name LIKE '京喜-清幽竹香%')";
|
||||
$jingxiSubsidySignature = 'ABS(COALESCE(paid_amount, 0) - COALESCE(product_amount, 0)) < 0.02';
|
||||
$jingxiSubsidyGuard = "platform = '京喜账单' AND ((" . $jingxiSubsidyStoreLikes . ') OR (' . $jingxiSubsidyFactoryStores . ' AND ' . $jingxiSubsidySignature . ')) AND ' . $effective . ' < -0.005';
|
||||
$rules['差额=直营补贴款(待开票结算)'] = $jingxiSubsidyGuard . ' AND ABS(COALESCE(refund_amount, 0)) < 0.005';
|
||||
$rules['差额=直营补贴款(订单退款已冲回)'] = $jingxiSubsidyGuard . ' AND ABS(COALESCE(refund_amount, 0)) >= 0.005';
|
||||
|
||||
@ -322,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;
|
||||
@ -360,26 +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;
|
||||
|
||||
$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 . ')';
|
||||
// 拼多多部分退款:账单收入高于聚水潭买家实付,且扣除优惠/冲减后仍有正向小额差额,
|
||||
// 与「多多支付立减」等平台支付类补贴一致;只标注原因,不改差额和收入。
|
||||
$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;
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/** @return list<string> 差额异常备注的下拉顺序(含兜底档) */
|
||||
private static function receiptDifferenceRemarkLabels(): array
|
||||
{
|
||||
return array_merge(array_keys(self::receiptDifferenceRemarkBaseRules()), ['差额待核实']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 差额异常备注的优先级序号(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++;
|
||||
}
|
||||
|
||||
// 兜底档:有差额但没命中任何原因 →「差额待核实」(与页面渲染的兜底备注保持一致)。
|
||||
$conditions['差额待核实'] = 'ABS(' . $effective . ') > 0.005 AND NOT (' . implode(' OR ', $seen) . ') AND ' . $notBasisMarked;
|
||||
|
||||
return $conditions;
|
||||
return $sql . ' WHEN ' . self::receiptReasonNotBasisMarkedSql() . ' THEN ' . $index . ' ELSE 0 END';
|
||||
}
|
||||
|
||||
/**
|
||||
* 该单的聚水潭应付(主单优先 pay_amount)是否大于账单收入(= 含国补/补贴类差额的口径)。
|
||||
*
|
||||
* 与 receiptDifferenceRemarkConditions() 里的聚水潭应付款子查询同口径;按单号缓存应付金额,
|
||||
* 与 receiptDifferenceRemarkBaseRules() 里的聚水潭应付款子查询同口径;按单号缓存应付金额,
|
||||
* 页面渲染与导出逐行调用时不会对同一单重复查询。
|
||||
*/
|
||||
private static function receiptJdSubsidyPayExceedsIncome(string $orderNo, float $income): bool
|
||||
@ -430,16 +524,31 @@ class Pages extends Controller
|
||||
return $cache[$orderNo];
|
||||
}
|
||||
|
||||
/** 该店铺是否适用京喜「直营补贴款」备注(前缀匹配,范围见 JINGXI_SUBSIDY_STORE_PREFIXES)。 */
|
||||
private static function isJingxiSubsidyStore(string $storeName): bool
|
||||
/** 京喜「直营补贴款」备注是否适用于该行(10 家核验店 + 工厂店按实付≈商品金额特征,与筛选条件同口径)。 */
|
||||
private static function jingxiSubsidyApplies(object $row): bool
|
||||
{
|
||||
$storeName = (string) ($row->store_name ?? '');
|
||||
foreach (self::JINGXI_SUBSIDY_STORE_PREFIXES as $prefix) {
|
||||
if (str_starts_with($storeName, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
$factoryStore = str_contains($storeName, '工厂店')
|
||||
|| str_starts_with($storeName, '京喜-香幽竹')
|
||||
|| str_starts_with($storeName, '京喜-清幽竹香');
|
||||
if (! $factoryStore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
/** 单行的「差额异常备注」(与筛选条件同一套口径,页面与导出共用)。 */
|
||||
@ -449,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
|
||||
@ -489,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) {
|
||||
@ -509,9 +641,21 @@ class Pages extends Controller
|
||||
return '订单实付取供货价';
|
||||
}
|
||||
}
|
||||
// 京喜正差额:账单货款含直营服务费(货款 = 商品金额 + 直营服务费),
|
||||
// 差额 = 直营服务费 −(实付−商品金额)。实付低于商品金额的单独标注。
|
||||
if (($row->platform ?? '') === '京喜账单' && $difference > 0.005) {
|
||||
$productAmount = (float) ($row->product_amount ?? 0);
|
||||
$supplyFee = (float) ($row->order_jingxi_gross_up_fee ?? 0);
|
||||
if ($productAmount > 0.005 && $supplyFee > 0.005
|
||||
&& abs(($income - $productAmount) - $supplyFee) < 0.02) {
|
||||
return (float) ($row->paid_amount ?? 0) < $productAmount - 0.03
|
||||
? '差额=货款含直营服务费(实付低于商品金额)'
|
||||
: '差额=货款含直营服务费(拆单/合并尾差)';
|
||||
}
|
||||
}
|
||||
// 京喜自营工厂店负差额:差额=京东「直营补贴款」(无退款→待开票结算;订单已退款→补贴款已随退款冲回)。
|
||||
if (($row->platform ?? '') === '京喜账单' && $difference < -0.005
|
||||
&& self::isJingxiSubsidyStore((string) ($row->store_name ?? ''))) {
|
||||
&& self::jingxiSubsidyApplies($row)) {
|
||||
return abs((float) ($row->refund_amount ?? 0)) >= 0.005
|
||||
? '差额=直营补贴款(订单退款已冲回)'
|
||||
: '差额=直营补贴款(待开票结算)';
|
||||
@ -583,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 ?? ''));
|
||||
@ -590,6 +745,23 @@ 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 '差额=分位四舍五入尾差';
|
||||
}
|
||||
|
||||
// 有差额但没命中任何已知原因:标「差额待核实」,页面上不再出现"有差额但备注空白"的行。
|
||||
return '差额待核实';
|
||||
}
|
||||
@ -652,24 +824,18 @@ class Pages extends Controller
|
||||
|
||||
public function home()
|
||||
{
|
||||
$section = collect(SidebarPermissions::navigationFor(user()))->first();
|
||||
$groups = \App\Utilities\SidebarMenu::groups(user());
|
||||
$sections = \App\Utilities\SidebarMenu::sections(user());
|
||||
|
||||
if (empty($section)) {
|
||||
if ($groups === [] && $sections === []) {
|
||||
return view('business.access-pending');
|
||||
}
|
||||
|
||||
$page = collect($section['pages'] ?? [])->first();
|
||||
$parameters = ['section' => $section['slug']];
|
||||
|
||||
if ($page) {
|
||||
$parameters['page'] = $page['slug'];
|
||||
|
||||
if (! empty($page['tabs'])) {
|
||||
$parameters['tab'] = $page['tabs'][0]['slug'];
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('business.pages.show', $parameters);
|
||||
// 登录后先落到首页导航,不再直接跳进第一个业务页面。
|
||||
return view('business.pages.home', [
|
||||
'groups' => $groups,
|
||||
'sections' => $sections,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $section, ?string $page = null, ?string $tab = null)
|
||||
@ -1158,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,
|
||||
@ -1175,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,
|
||||
@ -1200,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,
|
||||
];
|
||||
|
||||
@ -1211,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(),
|
||||
@ -1222,17 +1402,20 @@ class Pages extends Controller
|
||||
);
|
||||
$stats['selected_platforms'] = $platformFilter;
|
||||
$stats['selected_stores'] = $storeFilter;
|
||||
$stats['platform_options'] = array_values(array_unique(array_merge(
|
||||
self::RECEIPT_PLATFORM_OPTIONS,
|
||||
array_map('strval', $filterOptions['platforms'] ?? []),
|
||||
)));
|
||||
$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();
|
||||
|
||||
@ -1253,6 +1436,7 @@ class Pages extends Controller
|
||||
->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense')
|
||||
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_fee'] . ' END AS order_jingxi_supply_fee')
|
||||
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_net'] . ' END AS order_jingxi_supply_net')
|
||||
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['gross_up_fee'] . ' END AS order_jingxi_gross_up_fee')
|
||||
->orderBy('occurred_date')
|
||||
->orderBy('store_name')
|
||||
->orderBy('online_order_no')
|
||||
@ -1304,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)) . ')'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1376,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 === '') {
|
||||
@ -1442,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')
|
||||
@ -1505,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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个筛选项的可选值与行数:应用除该筛选项以外的所有条件。
|
||||
*
|
||||
@ -1559,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 = [];
|
||||
// 未匹配原因是页面级判定(只统计未匹配行,按库里的 remark 分组)
|
||||
if ($column === 'unmatched_reason') {
|
||||
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 分组)
|
||||
$base = clone $query;
|
||||
$rows = $query->where('match_status', '未匹配')
|
||||
->select('remark')
|
||||
@ -1594,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)
|
||||
@ -1606,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));
|
||||
@ -1692,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'],
|
||||
@ -1702,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'));
|
||||
@ -1744,7 +2103,8 @@ class Pages extends Controller
|
||||
->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['cancelled_sum'] . ' END AS order_cancelled_sum')
|
||||
->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense')
|
||||
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_fee'] . ' END AS order_jingxi_supply_fee')
|
||||
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_net'] . ' END AS order_jingxi_supply_net');
|
||||
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_net'] . ' END AS order_jingxi_supply_net')
|
||||
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['gross_up_fee'] . ' END AS order_jingxi_gross_up_fee');
|
||||
|
||||
$selected = array_values(array_intersect(
|
||||
array_filter(explode(',', (string) $request->query('columns', ''))),
|
||||
@ -1849,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, [
|
||||
@ -1870,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']);
|
||||
@ -1883,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();
|
||||
|
||||
@ -1961,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,
|
||||
@ -2100,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(),
|
||||
);
|
||||
@ -2126,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')
|
||||
@ -2240,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';
|
||||
|
||||
@ -2634,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 [
|
||||
@ -3280,11 +3616,14 @@ class Pages extends Controller
|
||||
'withdrawal-management' => $this->contentForWithdrawalManagement(),
|
||||
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
|
||||
'account-mapping' => $this->contentForAccountMapping(),
|
||||
'ledger' => app(\Modules\EcommerceFinance\View\FinanceLedgerPageData::class)->page($tab),
|
||||
'billing-details' => app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)->page(),
|
||||
'store-violation-alert' => $this->contentForStoreViolationAlerts(),
|
||||
'preview-statistics' => $this->contentForPreviewStatistics(),
|
||||
'planting-details' => $this->contentForPlantingDetails(),
|
||||
'jst-order-labels' => $this->contentForJstOrderLabels(),
|
||||
'planting-reimbursements' => app(\Modules\EcommerceFinance\View\PlantingReimbursementPageData::class)->page(),
|
||||
'planting-loss' => app(\Modules\EcommerceFinance\View\PlantingLossPageData::class)->page(),
|
||||
'finance-data' => app(\Modules\EcommerceFinance\View\SupplierReconciliationPageData::class)->financeData(),
|
||||
'supplier-data' => app(\Modules\EcommerceFinance\View\SupplierReconciliationPageData::class)->supplierData(),
|
||||
'store-report-data-logic' => $this->prepareStoreReportLogic(),
|
||||
@ -4731,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('导入核验');
|
||||
@ -4798,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');
|
||||
|
||||
|
||||
84
app/Http/Controllers/Business/PlantingWorkOrders.php
Normal file
84
app/Http/Controllers/Business/PlantingWorkOrders.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Services\PlantingLossCalculator;
|
||||
use Modules\EcommerceFinance\View\PlantingLossPageData;
|
||||
use Modules\EcommerceFinance\View\PlantingReimbursementPageData;
|
||||
|
||||
/** 种菜数据汇总:种菜报销 / 种菜损耗 导出(与页面同一筛选口径)。 */
|
||||
class PlantingWorkOrders extends Controller
|
||||
{
|
||||
public function exportReimbursements(PlantingReimbursementPageData $data)
|
||||
{
|
||||
$filters = $data->filters();
|
||||
|
||||
return $this->csv(
|
||||
PlantingReimbursementPageData::TITLE . '-' . $filters['month'],
|
||||
$data->exportColumns(),
|
||||
static fn () => $data->rows($filters),
|
||||
);
|
||||
}
|
||||
|
||||
public function exportLoss(PlantingLossPageData $data)
|
||||
{
|
||||
$filters = $data->filters();
|
||||
$suffix = match ($filters['result']) {
|
||||
PlantingLossCalculator::RESULT_EXEMPT => '-不扣款拍A发B',
|
||||
PlantingLossCalculator::RESULT_FREE_GOODS => '-不扣款虚拟条码挂钩',
|
||||
PlantingLossCalculator::RESULT_UNMATCHED => '-未匹配发货',
|
||||
'all' => '-全部',
|
||||
default => '-扣款',
|
||||
};
|
||||
|
||||
// 发运营的拍A发B 名单直接按「种菜报销」的字段导出。
|
||||
if (request()->query('format') === 'reimbursement') {
|
||||
return $this->csv(
|
||||
'拍A发B发运营-' . $filters['month'],
|
||||
PlantingReimbursementPageData::COLUMNS,
|
||||
static fn () => $data->rows($filters),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->csv(
|
||||
PlantingLossPageData::TITLE . '-' . $filters['month'] . $suffix,
|
||||
$data->exportColumns(),
|
||||
static fn () => $data->rows($filters),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,string> $columns @param callable():iterable<array<string,mixed>> $rows */
|
||||
private function csv(string $name, array $columns, callable $rows)
|
||||
{
|
||||
$file = tmpfile();
|
||||
abort_if($file === false, 500, '无法创建导出文件。');
|
||||
|
||||
try {
|
||||
fwrite($file, "\xEF\xBB\xBF");
|
||||
fputcsv($file, array_values($columns));
|
||||
foreach ($rows() as $row) {
|
||||
$values = [];
|
||||
foreach (array_keys($columns) as $key) {
|
||||
$values[] = $row[$key] ?? '';
|
||||
}
|
||||
fputcsv($file, array_map(static function (mixed $value): string {
|
||||
$value = (string) $value;
|
||||
|
||||
// 长数字单号/账号保留文本,避免 Excel 截断;同时阻止公式执行。
|
||||
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
|
||||
}, $values));
|
||||
}
|
||||
rewind($file);
|
||||
} catch (\Throwable $exception) {
|
||||
fclose($file);
|
||||
report($exception);
|
||||
abort(503, '聚工单镜像或发货数据未就绪,未生成不完整的导出文件,请稍后重试。');
|
||||
}
|
||||
|
||||
return response()->streamDownload(static function () use ($file): void {
|
||||
fpassthru($file);
|
||||
fclose($file);
|
||||
}, $name . '-' . now()->format('YmdHis') . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
}
|
||||
100
app/Http/Controllers/Business/SupplierBookkeepingConfigs.php
Normal file
100
app/Http/Controllers/Business/SupplierBookkeepingConfigs.php
Normal file
@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Validation\Rule as ValidationRule;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
|
||||
use Modules\EcommerceFinance\View\SupplierBookkeepingConfigPageData;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
|
||||
class SupplierBookkeepingConfigs extends Controller
|
||||
{
|
||||
public function __construct(private readonly SupplierBookkeepingConfigStore $store) {}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->store->save($this->validated($request), $this->actor());
|
||||
|
||||
return $this->back('厂家记账配置已新增,厂家记账明细按新配置重新判定。');
|
||||
}
|
||||
|
||||
public function update(Request $request, int $configId)
|
||||
{
|
||||
$this->store->save($this->validated($request), $this->actor(), $configId);
|
||||
|
||||
return $this->back('厂家记账配置已更新,厂家记账明细按新配置重新判定。');
|
||||
}
|
||||
|
||||
public function destroy(int $configId)
|
||||
{
|
||||
$this->store->changeDeleted($configId, true, $this->actor());
|
||||
|
||||
return $this->back('配置已删除,该供应商视同不在配置表,按正常记账扣款。');
|
||||
}
|
||||
|
||||
public function restore(int $configId)
|
||||
{
|
||||
$this->store->changeDeleted($configId, false, $this->actor());
|
||||
|
||||
return $this->back('厂家记账配置已恢复。');
|
||||
}
|
||||
|
||||
public function import(Request $request, SupplierBookkeepingConfigImporter $importer)
|
||||
{
|
||||
$request->validate(['file' => ['required', 'file', 'mimes:xlsx', 'max:10240']]);
|
||||
$path = $request->file('file')->getRealPath();
|
||||
$result = $this->store->import($importer->read($path), $this->actor(), hash_file('sha256', $path));
|
||||
|
||||
return $this->back("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。");
|
||||
}
|
||||
|
||||
public function export(SupplierBookkeepingConfigPageData $data)
|
||||
{
|
||||
$rows = $this->store->filtered($data->filters())->get();
|
||||
$book = new Spreadsheet();
|
||||
$sheet = $book->getActiveSheet();
|
||||
$sheet->setTitle('厂家记账配置');
|
||||
$sheet->fromArray([['供应商', '记账方式', '生效开始日期', '生效结束日期', '原始说明', '创建人', '更新时间', '删除状态']]);
|
||||
foreach ($rows as $index => $row) {
|
||||
$values = [$row->supplier_name, Rule::MODES[$row->mode] ?? $row->mode, $row->effective_from, $row->effective_to, $row->source_text, $row->creator, $row->updated_at, $row->deleted_at ? '已删除' : '正常'];
|
||||
foreach ($values as $column => $value) {
|
||||
$sheet->setCellValueExplicit([$column + 1, $index + 2], (string) $value, DataType::TYPE_STRING);
|
||||
}
|
||||
}
|
||||
foreach (['A' => 24, 'B' => 20, 'C' => 14, 'D' => 14, 'E' => 44, 'F' => 16, 'G' => 20, 'H' => 10] as $column => $width) {
|
||||
$sheet->getColumnDimension($column)->setWidth($width);
|
||||
}
|
||||
$sheet->freezePane('A2');
|
||||
|
||||
return response()->streamDownload(function () use ($book): void {
|
||||
try { (new Xlsx($book))->save('php://output'); } finally { $book->disconnectWorksheets(); }
|
||||
}, '厂家记账配置.xlsx', ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']);
|
||||
}
|
||||
|
||||
private function validated(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'supplier_name' => ['required', 'string', 'max:191'],
|
||||
'mode' => ['required', ValidationRule::in(array_keys(Rule::MODES))],
|
||||
'effective_from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'effective_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_from'],
|
||||
'source_text' => ['nullable', 'string', 'max:255'],
|
||||
], [], ['supplier_name' => '供应商', 'mode' => '记账方式', 'effective_from' => '生效开始日期', 'effective_to' => '生效结束日期', 'source_text' => '备注']);
|
||||
}
|
||||
|
||||
private function actor(): string
|
||||
{
|
||||
return mb_substr((string) user()->name, 0, 191);
|
||||
}
|
||||
|
||||
private function back(string $message)
|
||||
{
|
||||
return redirect()->route('business.pages.show', ['section' => 'supplier-payables', 'page' => 'manufacturer-bookkeeping', 'bk_view' => 'config'])->with('bookkeeping_config_message', $message);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
31
app/Utilities/FinanceLedgerAccess.php
Normal file
31
app/Utilities/FinanceLedgerAccess.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
use App\Models\Auth\User;
|
||||
|
||||
class FinanceLedgerAccess
|
||||
{
|
||||
/**
|
||||
* 账户映射的写权限:超级管理员,或配置中的财务台账管理员角色。
|
||||
* 页面查看权限仍由 SidebarPermissions 的页面键控制。
|
||||
*/
|
||||
public static function canEditMapping(?User $user = null): bool
|
||||
{
|
||||
if (SystemAccess::isSuperAdmin($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user ??= user();
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) config('finance_ledger.mapping_editor_roles', '财务台账管理员')),
|
||||
)));
|
||||
|
||||
return in_array(SystemAccess::roleName($user), $roles, true);
|
||||
}
|
||||
}
|
||||
18
app/Utilities/FocusMode.php
Normal file
18
app/Utilities/FocusMode.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
class FocusMode
|
||||
{
|
||||
/**
|
||||
* 单页模式(钉钉工作台入口,或显式 ?focus=1):只保留该目录自己的页签,隐藏全局菜单。
|
||||
* 网页端正常登录时不启用,避免页面上的目录页签和左侧菜单、已打开页面标签栏重复。
|
||||
*/
|
||||
public static function activeFor(?string $sectionSlug = null): bool
|
||||
{
|
||||
$section = $sectionSlug ?: (string) (request()->route('section') ?: request()->segment(3));
|
||||
|
||||
return in_array($section, ['financial-risk-control'], true)
|
||||
&& (request()->boolean('focus') || str_contains((string) request()->userAgent(), 'DingTalk'));
|
||||
}
|
||||
}
|
||||
139
app/Utilities/SidebarMenu.php
Normal file
139
app/Utilities/SidebarMenu.php
Normal file
@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
use App\Models\Auth\User;
|
||||
|
||||
class SidebarMenu
|
||||
{
|
||||
/**
|
||||
* 侧边栏与首页共用的功能分组,按当前账号可访问的页面过滤,标题跟随后台自定义名称。
|
||||
*
|
||||
* @return array<int, array{title: string, items: array<int, array{section: string, page: string, title: string, icon: string}>}>
|
||||
*/
|
||||
public static function groups(?User $user = null): array
|
||||
{
|
||||
$user ??= user();
|
||||
|
||||
$groups = [
|
||||
['title' => '工作台', 'items' => [
|
||||
['workspace', 'overview', '经营总览', 'monitoring'],
|
||||
]],
|
||||
['title' => '经营报表', 'items' => [
|
||||
['company-management', 'subject-operating-report', '主体维度经营报表', 'domain'],
|
||||
['company-management', 'company-cost-arrangement', '公司成本安排', 'account_balance_wallet'],
|
||||
['company-management', 'tax-forecast', '税金预测表', 'calculate'],
|
||||
['store-management', 'store-report', '店铺维度报表', 'storefront'],
|
||||
['store-management', 'internal-flow-report', '内部流转报表', 'swap_horiz'],
|
||||
['company-management', 'personnel-inventory', '人员盘点表', 'groups'],
|
||||
]],
|
||||
['title' => '基础资料', 'items' => [
|
||||
['store-management', 'basic-information', '店铺基础信息', 'shopping_bag'],
|
||||
['store-management', 'store-accounts', '店铺账号密码', 'key'],
|
||||
['company-management', 'basic-information', '公司基础信息', 'badge'],
|
||||
['trademark-management', 'information', '商标基础信息', 'workspace_premium'],
|
||||
]],
|
||||
['title' => '规则信息', 'items' => [
|
||||
['trademark-management', 'subject-trademark-rules', '主体商标规则', 'rule'],
|
||||
['trademark-management', 'store-change-rules', '店铺变更规则', 'rule'],
|
||||
['trademark-management', 'store-report-data-logic', '店铺维度报表逻辑配置', 'table_chart'],
|
||||
['trademark-management', 'tax-forecast-data-logic', '税金预测表逻辑配置', 'calculate'],
|
||||
]],
|
||||
['title' => '业务进度', 'items' => [
|
||||
['company-management', 'company-progress', '公司进度表', 'timeline'],
|
||||
['store-management', 'change-progress', '店铺变更进度表', 'swap_horiz'],
|
||||
['trademark-management', 'trademark-progress', '商标进度表', 'format_list_bulleted'],
|
||||
['workspace', 'tasks', '待办事项', 'assignment_turned_in'],
|
||||
['workspace', 'warnings', '预警页面', 'warning_amber'],
|
||||
]],
|
||||
['title' => '财务报表', 'items' => [
|
||||
['prototype-finance-reports', 'profit-report', '公司利润报表', 'request_quote'],
|
||||
['prototype-finance-reports', 'asset-report', '公司资产报表', 'account_balance'],
|
||||
]],
|
||||
];
|
||||
|
||||
$visiblePages = self::visiblePages($user);
|
||||
|
||||
$groups = array_map(function (array $group) use ($visiblePages): array {
|
||||
$items = [];
|
||||
|
||||
foreach ($group['items'] as [$section, $page, $title, $icon]) {
|
||||
$key = $section . '/' . $page;
|
||||
|
||||
if (! isset($visiblePages[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'section' => $section,
|
||||
'page' => $page,
|
||||
'title' => $visiblePages[$key],
|
||||
'icon' => $icon,
|
||||
];
|
||||
}
|
||||
|
||||
$group['items'] = $items;
|
||||
|
||||
return $group;
|
||||
}, $groups);
|
||||
|
||||
return array_values(array_filter($groups, static fn (array $group): bool => $group['items'] !== []));
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前账号可访问的全部业务模块及页面,供首页做完整导航。
|
||||
*
|
||||
* @return array<int, array{slug: string, title: string, icon: string, pages: array<int, array{slug: string, title: string}>}>
|
||||
*/
|
||||
public static function sections(?User $user = null): array
|
||||
{
|
||||
$user ??= user();
|
||||
|
||||
$sections = [];
|
||||
|
||||
foreach (SidebarPermissions::navigationFor($user) as $section) {
|
||||
$pages = [];
|
||||
|
||||
foreach ($section['pages'] ?? [] as $page) {
|
||||
$pages[] = ['slug' => $page['slug'], 'title' => $page['title']];
|
||||
|
||||
foreach ($page['children'] ?? [] as $child) {
|
||||
$pages[] = ['slug' => $child['slug'], 'title' => $child['title']];
|
||||
}
|
||||
}
|
||||
|
||||
if ($pages === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sections[] = [
|
||||
'slug' => $section['slug'],
|
||||
'title' => $section['title'],
|
||||
'icon' => $section['icon'] ?? 'folder',
|
||||
'pages' => $pages,
|
||||
];
|
||||
}
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> 「section/page」 => 页面标题
|
||||
*/
|
||||
private static function visiblePages(User $user): array
|
||||
{
|
||||
$pages = [];
|
||||
|
||||
foreach (SidebarPermissions::navigationFor($user) as $section) {
|
||||
foreach ($section['pages'] ?? [] as $page) {
|
||||
$pages[$section['slug'] . '/' . $page['slug']] = $page['title'];
|
||||
|
||||
foreach ($page['children'] ?? [] as $child) {
|
||||
$pages[$section['slug'] . '/' . $child['slug']] = $child['title'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $pages;
|
||||
}
|
||||
}
|
||||
@ -68,6 +68,7 @@ services:
|
||||
volumes:
|
||||
- caiwu_clickhouse_database:/var/lib/clickhouse
|
||||
- caiwu_clickhouse_logs:/var/log/clickhouse-server
|
||||
- ./clickhouse-resource-policy.xml:/etc/clickhouse-server/config.d/caiwu-resource-policy.xml:ro
|
||||
mem_limit: 4g
|
||||
cpus: "2.00"
|
||||
ulimits:
|
||||
@ -100,6 +101,7 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-0}
|
||||
FINANCE_BATCH_WORKER_ENABLED: "1"
|
||||
SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION: business_mirror
|
||||
JST_DB_CONNECTION: business_mirror
|
||||
env_file:
|
||||
@ -121,6 +123,35 @@ services:
|
||||
retries: 10
|
||||
start_period: 45s
|
||||
|
||||
# Heavy CLI jobs use a separate cgroup, not Apache's unbounded app container.
|
||||
batch:
|
||||
image: caiwu_akaunting:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||
pull_policy: never
|
||||
profiles: [batch]
|
||||
user: www-data
|
||||
entrypoint: [php, artisan]
|
||||
command: [list]
|
||||
restart: "no"
|
||||
environment:
|
||||
SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION: business_mirror
|
||||
JST_DB_CONNECTION: business_mirror
|
||||
env_file:
|
||||
- ./runtime/qiniu-storage.env
|
||||
- ./runtime/clickhouse.env
|
||||
volumes:
|
||||
- ./runtime/.env:/var/www/html/.env:ro
|
||||
- caiwu_storage:/var/www/html/storage
|
||||
cpus: "1.00"
|
||||
mem_limit: 3g
|
||||
memswap_limit: 3g
|
||||
healthcheck:
|
||||
disable: true
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
caiwu_database:
|
||||
name: caiwu_database
|
||||
|
||||
@ -180,7 +180,7 @@ return [
|
||||
'store_report_paid_sales_rows' => [
|
||||
'role' => 'aggregate',
|
||||
'target_store' => 'mariadb',
|
||||
'consumer' => '店铺维度报表销量(业绩明细订单实付/实收金额)',
|
||||
'consumer' => '店铺维度报表销量(业绩明细收入+优惠+冲减+退款金额合计)',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@ -252,7 +252,7 @@ $navigation = [
|
||||
'directory' => [
|
||||
'icon' => 'query_stats',
|
||||
'status' => '共享镜像',
|
||||
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「订单实付/实收金额」,按店铺和发生时间汇总后落到主体期间。',
|
||||
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按店铺和发生日期逐日汇总后落到主体期间。',
|
||||
'empty' => '店铺维度报表尚未完成共享数据导入。',
|
||||
'fields' => [
|
||||
['name' => 'erp_store_name', 'label' => 'ERP系统店铺名称'],
|
||||
@ -540,6 +540,8 @@ $navigation = [
|
||||
'pages' => [
|
||||
['slug' => 'planting-details', 'title' => '种菜明细表'],
|
||||
['slug' => 'jst-order-labels', 'title' => '聚水潭订单标签明细'],
|
||||
['slug' => 'planting-reimbursements', 'title' => '种菜报销'],
|
||||
['slug' => 'planting-loss', 'title' => '种菜损耗'],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -820,7 +822,7 @@ $navigation = [
|
||||
'directory' => [
|
||||
'icon' => 'receipt_long',
|
||||
'status' => '共享镜像',
|
||||
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注)、差额=直营补贴款(待开票结算)(仅京喜账单·出类拔萃等 10 家京喜自营工厂店负差额且无退款(已逐店在结算单核实):京东直营补贴款待开票结算时标注)、差额=直营补贴款(订单退款已冲回)(同批店负差额但订单已退款:补贴款已随退款冲回)、差额=订单实付包含商家承担优惠和以旧换新国补(仅京东账单·索菲亚客厅/办公/书房/功能、亿家达全屋、朴美家居店负差额、且该单含国补(聚水潭应付−账单收入>0):差额为商家承担优惠(店铺东券/单品折扣等)与以旧换新国补之和,已用营销对账逐单核实)、差额=订单实付包含商家承担优惠(同店负差额但不含国补的单:差额即商家承担优惠)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)(以上三档等式对带口径标识的行让位给口径兜底)、订单实付含已退款部分(抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注;淘系支付宝/微信负差额:差额=退款合计、或退款合计=实付−收入(收款缺口)、或整单全退(退款合计≈实付)时同样标注(退款取聚水潭已确认记录));都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
|
||||
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注)、差额=直营补贴款(待开票结算)(仅京喜账单·京喜自营工厂店负差额且无退款(出类拔萃等 10 家已逐店核验,其余工厂店按实付≈商品金额的供货价特征识别):京东直营补贴款待开票结算时标注)、差额=直营补贴款(订单退款已冲回)(同批店负差额但订单已退款:补贴款已随退款冲回)、差额=订单实付包含商家承担优惠和以旧换新国补(仅京东账单·索菲亚客厅/办公/书房/功能、亿家达全屋、朴美家居店负差额、且该单含国补(聚水潭应付−账单收入>0):差额为商家承担优惠(店铺东券/单品折扣等)与以旧换新国补之和,已用营销对账逐单核实)、差额=订单实付包含商家承担优惠(同店负差额但不含国补的单:差额即商家承担优惠)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)(以上三档等式对带口径标识的行让位给口径兜底)、订单实付含已退款部分(抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注;淘系支付宝/微信负差额:差额=退款合计、或退款合计=实付−收入(收款缺口)、或整单全退(退款合计≈实付)时同样标注(退款取聚水潭已确认记录));都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
|
||||
'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。',
|
||||
'fields' => [
|
||||
['name' => 'platform', 'label' => '平台'],
|
||||
@ -832,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'],
|
||||
@ -888,7 +892,7 @@ $navigation = [
|
||||
['slug' => 'preview-statistics', 'title' => '预览统计'],
|
||||
[
|
||||
'slug' => 'process-statistics',
|
||||
'title' => '流程统计',
|
||||
'title' => '店铺流程',
|
||||
'directory' => [
|
||||
'icon' => 'timeline',
|
||||
'status' => '共享镜像',
|
||||
@ -939,6 +943,14 @@ $navigation = [
|
||||
['slug' => 'account-mapping', 'title' => '科目映射表'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'financial-ledger',
|
||||
'title' => '财务台账',
|
||||
'icon' => 'menu_book',
|
||||
'pages' => [
|
||||
['slug' => 'ledger', 'title' => '财务台账'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'data-monitoring',
|
||||
'title' => '数据监控',
|
||||
|
||||
26
config/finance_ledger.php
Normal file
26
config/finance_ledger.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// 台账期初日,也是每日资金流水滚动的起点。
|
||||
'opening_date' => env('FINANCE_LEDGER_OPENING_DATE', '2026-10-17'),
|
||||
|
||||
// 台账取数窗口。期初日可以晚于窗口结束日,二者互不影响。
|
||||
'window_start' => env('FINANCE_LEDGER_WINDOW_START', '2026-04-18'),
|
||||
// 留空表示同步到当天;首次建账快照截止日由导入文件自身决定。
|
||||
'window_end' => env('FINANCE_LEDGER_WINDOW_END', ''),
|
||||
|
||||
// 动账明细的唯一上游来源;本地调试可切换到本地快照导入。
|
||||
'source' => env('FINANCE_LEDGER_RPT_SOURCE', 'operations_replica'),
|
||||
|
||||
// 具备账户映射写权限的角色;超级管理员始终可写。
|
||||
'mapping_editor_roles' => env('FINANCE_LEDGER_MAPPING_EDITOR_ROLES', '财务台账管理员'),
|
||||
|
||||
'dingtalk_process_codes' => [
|
||||
'PROC-B290696F-EC2A-4F76-87BA-E0A12E10D09F' => '日常费用报销流程',
|
||||
'PROC-C5159B8D-F412-4DF8-981D-64EBB8D03EB6' => '种菜报销流程',
|
||||
'PROC-39DD70DE-DAB2-4A5F-B8F1-0B0B68BD0C88' => '应付申请流程',
|
||||
'PROC-BBAC7187-9E59-4942-941A-59C7EAF0CEDA' => '快递面单应付流程',
|
||||
'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF' => '线上代发业务报销',
|
||||
'PROC-6407E98B-040A-4C96-AA7D-696650FC1A25' => '固定资产报废或出售流程',
|
||||
],
|
||||
];
|
||||
16
config/sidebar_links.php
Normal file
16
config/sidebar_links.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 侧边栏「常用网址」
|
||||
|--------------------------------------------------------------------------
|
||||
| 搜索框右侧的网址入口,按数组顺序展示,点击在新标签页打开。
|
||||
| 例:['title' => '聚水潭 ERP', 'url' => 'https://example.com'],
|
||||
*/
|
||||
|
||||
return [
|
||||
['title' => '聚水潭', 'url' => 'https://w.erp321.com/login.aspx'],
|
||||
['title' => '工作平台', 'url' => 'https://www.jiansun.vip/MiniUI/Index'],
|
||||
['title' => '预策', 'url' => 'http://218.93.9.141:8088/#/site-view/RPucab5JJI/J18wWGR1uC'],
|
||||
['title' => '由壹点「脉象」', 'url' => 'https://yyd.jiansuntuzhi.cn/login'],
|
||||
];
|
||||
@ -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),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'batch_worker_enabled' => (bool) env('FINANCE_BATCH_WORKER_ENABLED', false),
|
||||
// Connection records supply addresses and credentials, never these defaults.
|
||||
'defaults' => ['enabled' => false, 'connection' => ['connect_timeout' => 5, 'query_timeout' => 15]],
|
||||
'sync' => [
|
||||
|
||||
@ -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,66 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 供应商应付「厂家记账」配置:按供应商记录厂家记账方式(正常记账 / 仅代发仓发错货记账 / 不记账)及生效期。
|
||||
* 与聚工单镜像一样不区分公司;建表后导入财务提供的存量配置(resources/data/supplier-bookkeeping-configs.json)。
|
||||
*/
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('business_supplier_bookkeeping_configs', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->string('supplier_name', 191)->unique('supplier_bookkeeping_config_supplier_unique');
|
||||
$table->string('mode', 32);
|
||||
$table->date('effective_from')->nullable();
|
||||
$table->date('effective_to')->nullable();
|
||||
$table->string('source_text', 255)->default('');
|
||||
$table->string('creator', 191);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
Schema::create('business_supplier_bookkeeping_config_events', function (Blueprint $table): void {
|
||||
$table->increments('id');
|
||||
$table->unsignedInteger('config_id')->nullable()->index();
|
||||
$table->string('actor', 191);
|
||||
$table->string('action', 32);
|
||||
$table->text('description');
|
||||
$table->longText('details');
|
||||
$table->dateTime('created_at');
|
||||
});
|
||||
|
||||
$payload = json_decode((string) file_get_contents(resource_path('data/supplier-bookkeeping-configs.json')), true, 16, JSON_THROW_ON_ERROR);
|
||||
$now = now(config('app.platform_timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s');
|
||||
$rows = array_map(static fn (array $row): array => [
|
||||
'supplier_name' => $row['supplier_name'],
|
||||
'mode' => $row['mode'],
|
||||
'effective_from' => $row['effective_from'],
|
||||
'effective_to' => $row['effective_to'],
|
||||
'source_text' => $row['source_text'],
|
||||
'creator' => '存量配置导入',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
], $payload['configs']);
|
||||
foreach (array_chunk($rows, 100) as $chunk) {
|
||||
DB::table('business_supplier_bookkeeping_configs')->insert($chunk);
|
||||
}
|
||||
DB::table('business_supplier_bookkeeping_config_events')->insert([
|
||||
'config_id' => null,
|
||||
'actor' => '存量配置导入',
|
||||
'action' => 'import',
|
||||
'description' => '导入存量厂家记账配置:新增' . count($rows) . '条',
|
||||
'details' => json_encode(['added' => count($rows), 'source' => $payload['source']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('business_supplier_bookkeeping_config_events');
|
||||
Schema::dropIfExists('business_supplier_bookkeeping_configs');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ec_account_mapping_rules', function (Blueprint $table): void {
|
||||
$table->string('fee_memo', 191)->default('')->after('category');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('ec_account_mapping_rules', function (Blueprint $table): void {
|
||||
$table->dropColumn('fee_memo');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 科目映射表变更后,自动重跑业绩明细的待办队列。
|
||||
*
|
||||
* 一条 = 一个「同步平台 × 月份」,由后台命令 store-receipt:auto-resync 逐个消费。
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ec_account_mapping_resync_jobs', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('platform', 64);
|
||||
$table->string('month', 7);
|
||||
$table->string('status', 16)->default('pending');
|
||||
$table->unsignedInteger('attempts')->default(0);
|
||||
$table->text('message')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['platform', 'month'], 'ec_account_mapping_resync_unique');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ec_account_mapping_resync_jobs');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 财务台账:账户、店铺、公司、期初、动账明细、每日流水和账户映射。
|
||||
*
|
||||
* 财务台账是财务系统自己的业务主数据和派生数据,不是上游镜像,
|
||||
* 因此统一落在 caiwu_business_mirror;不注册到 upstream_mirrors。
|
||||
*/
|
||||
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('fin_accounts')) {
|
||||
$schema->create('fin_accounts', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('company_name', 100);
|
||||
$table->string('account_short', 100);
|
||||
$table->string('region', 50)->nullable();
|
||||
$table->string('account_no', 50)->nullable();
|
||||
$table->string('bank_name', 200)->nullable();
|
||||
$table->string('bank_city_branch', 100)->nullable();
|
||||
$table->string('account_type', 50)->nullable();
|
||||
$table->string('company_plan', 50)->nullable();
|
||||
$table->string('account_last4', 50)->nullable();
|
||||
$table->string('single_limit', 50)->nullable();
|
||||
$table->string('daily_limit', 50)->nullable();
|
||||
$table->string('monthly_limit', 50)->nullable();
|
||||
$table->string('yearly_limit', 50)->nullable();
|
||||
$table->decimal('current_balance', 18, 2)->default(0);
|
||||
$table->decimal('blocked_amount', 18, 2)->default(0);
|
||||
$table->decimal('daily_available', 18, 2)->default(0);
|
||||
$table->decimal('clearing_days', 10, 2)->nullable();
|
||||
$table->string('raise_target', 50)->nullable();
|
||||
$table->string('raise_status', 50)->nullable();
|
||||
$table->string('raise_actions', 500)->nullable();
|
||||
$table->string('done_actions', 500)->nullable();
|
||||
$table->string('next_action', 200)->nullable();
|
||||
$table->string('remark', 500)->nullable();
|
||||
$table->string('owner', 50)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['company_name'], 'fin_accounts_company_index');
|
||||
$table->index(['account_short'], 'fin_accounts_short_index');
|
||||
$table->index(['owner'], 'fin_accounts_owner_index');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('fin_shops')) {
|
||||
$schema->create('fin_shops', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('shop_name', 200);
|
||||
$table->string('platform', 50)->nullable();
|
||||
$table->string('company_name', 100)->nullable();
|
||||
$table->string('account_short', 100)->nullable();
|
||||
$table->string('status', 50)->nullable();
|
||||
$table->string('payout_account', 100)->nullable();
|
||||
$table->decimal('shop_balance', 18, 2)->default(0);
|
||||
$table->string('remark', 500)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['shop_name'], 'fin_shops_name_unique');
|
||||
$table->index(['company_name'], 'fin_shops_company_index');
|
||||
$table->index(['platform'], 'fin_shops_platform_index');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('fin_companies')) {
|
||||
$schema->create('fin_companies', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('name', 200);
|
||||
$table->string('register_place', 200)->nullable();
|
||||
$table->string('business_type', 50)->nullable();
|
||||
$table->string('tax_type', 50)->nullable();
|
||||
$table->string('status', 20)->nullable();
|
||||
$table->string('basic_bank', 200)->nullable();
|
||||
$table->string('basic_account_last4', 50)->nullable();
|
||||
$table->string('remark', 500)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['name'], 'fin_companies_name_unique');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('fin_account_opening')) {
|
||||
$schema->create('fin_account_opening', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('account_short', 100);
|
||||
$table->date('opening_date');
|
||||
$table->decimal('opening_balance', 18, 2)->default(0);
|
||||
$table->string('remark', 200)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['account_short', 'opening_date'], 'fin_opening_account_date_unique');
|
||||
$table->index(['opening_date'], 'fin_opening_date_index');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('fin_transactions')) {
|
||||
$schema->create('fin_transactions', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->date('txn_date');
|
||||
$table->string('account_short', 100)->nullable();
|
||||
$table->text('detail')->nullable();
|
||||
$table->decimal('income', 18, 2)->default(0);
|
||||
$table->decimal('expense', 18, 2)->default(0);
|
||||
$table->string('approval_no', 100)->nullable();
|
||||
$table->string('source', 20);
|
||||
$table->string('source_value', 200)->nullable();
|
||||
$table->string('process_type', 50)->nullable();
|
||||
$table->string('row_key', 64);
|
||||
$table->timestamp('synced_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['row_key'], 'fin_transactions_row_key_unique');
|
||||
$table->index(['txn_date'], 'fin_transactions_date_index');
|
||||
$table->index(['account_short'], 'fin_transactions_account_index');
|
||||
$table->index(['source', 'source_value'], 'fin_transactions_source_index');
|
||||
$table->index(['approval_no'], 'fin_transactions_approval_index');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('fin_daily_flows')) {
|
||||
$schema->create('fin_daily_flows', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->date('flow_date');
|
||||
$table->string('company_name', 100)->nullable();
|
||||
$table->string('account_short', 100);
|
||||
$table->decimal('opening_balance', 18, 2)->default(0);
|
||||
$table->decimal('inflow', 18, 2)->default(0);
|
||||
$table->decimal('outflow', 18, 2)->default(0);
|
||||
$table->decimal('closing_balance', 18, 2)->default(0);
|
||||
$table->string('remark', 200)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['flow_date', 'account_short'], 'fin_flows_date_account_unique');
|
||||
$table->index(['account_short'], 'fin_flows_account_index');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('fin_account_map')) {
|
||||
$schema->create('fin_account_map', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('source', 20);
|
||||
$table->string('source_value', 200);
|
||||
$table->string('account_short', 100)->nullable();
|
||||
$table->unsignedInteger('txn_count')->default(0);
|
||||
$table->decimal('income_sum', 18, 2)->default(0);
|
||||
$table->decimal('expense_sum', 18, 2)->default(0);
|
||||
$table->string('updated_by', 50)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['source', 'source_value'], 'fin_account_map_source_unique');
|
||||
$table->index(['account_short'], 'fin_account_map_account_index');
|
||||
});
|
||||
}
|
||||
|
||||
if (! $schema->hasTable('fin_account_map_events')) {
|
||||
$schema->create('fin_account_map_events', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('source', 20);
|
||||
$table->string('source_value', 200);
|
||||
$table->string('old_account_short', 100)->nullable();
|
||||
$table->string('new_account_short', 100)->nullable();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('user_name', 100)->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['source', 'source_value'], 'fin_map_events_source_index');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$schema = Schema::connection($this->connection());
|
||||
|
||||
foreach ([
|
||||
'fin_account_map_events',
|
||||
'fin_account_map',
|
||||
'fin_daily_flows',
|
||||
'fin_transactions',
|
||||
'fin_account_opening',
|
||||
'fin_companies',
|
||||
'fin_shops',
|
||||
'fin_accounts',
|
||||
] as $table) {
|
||||
$schema->dropIfExists($table);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -189,13 +203,15 @@ CRON_TZ=Asia/Shanghai
|
||||
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /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 upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
|
||||
# 店铺经营报表按 15 分钟刷新;单轮覆盖 50000 行回看窗口,避免空闲时持续重读千万级账单。
|
||||
7,22,37,52 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --max-batches=12 --quiet >/dev/null 2>&1
|
||||
# 订单售后费用明细的店铺候选:完整列表要扫两张百万级订单宽表(约 2 分钟),放定时任务预热;
|
||||
# 页面冷缓存时先用毫秒级的店铺目录兜底,避免请求一直不结束、页面一直转圈。
|
||||
# 店铺候选平时仅读镜像变更;夜间全量核对历史名称。冷页面不扫描订单宽表。
|
||||
45 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan order-data:warm-shop-candidates --quiet >/dev/null 2>&1
|
||||
45 2 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh order-data:warm-shop-candidates --full --quiet >/dev/null 2>&1
|
||||
# 页面字段自动跟随上游:先刷新数据字典,再把上游新增字段补进镜像并建列,页面自动多出这一列。
|
||||
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
|
||||
@ -205,7 +221,12 @@ CRON_TZ=Asia/Shanghai
|
||||
# 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。
|
||||
15 8 * * * 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-supplier-management --quiet >/dev/null 2>&1
|
||||
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
|
||||
28 1 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan jushuitan:sync-shipping --days=3 >/dev/null 2>&1
|
||||
# 财务台账:每天 02:30 从 AI 库 rpt 同步动账明细并重算流水。
|
||||
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 定点把该渠道所用
|
||||
# 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。
|
||||
30 9 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_originalBillInfo --max-batches=20 --quiet >/dev/null 2>&1
|
||||
|
||||
@ -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
|
||||
|
||||
@ -193,13 +207,15 @@ CRON_TZ=Asia/Shanghai
|
||||
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /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 upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
|
||||
# 店铺经营报表按 15 分钟刷新;单轮覆盖 50000 行回看窗口,避免空闲时持续重读千万级账单。
|
||||
7,22,37,52 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --max-batches=12 --quiet >/dev/null 2>&1
|
||||
# 订单售后费用明细的店铺候选:完整列表要扫两张百万级订单宽表(约 2 分钟),放定时任务预热;
|
||||
# 页面冷缓存时先用毫秒级的店铺目录兜底,避免请求一直不结束、页面一直转圈。
|
||||
# 店铺候选平时仅读镜像变更;夜间全量核对历史名称。冷页面不扫描订单宽表。
|
||||
45 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan order-data:warm-shop-candidates --quiet >/dev/null 2>&1
|
||||
45 2 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh order-data:warm-shop-candidates --full --quiet >/dev/null 2>&1
|
||||
# 页面字段自动跟随上游:先刷新数据字典,再把上游新增字段补进镜像并建列,页面自动多出这一列。
|
||||
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,7 +225,12 @@ CRON_TZ=Asia/Shanghai
|
||||
# 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。
|
||||
15 8 * * * 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-supplier-management --quiet >/dev/null 2>&1
|
||||
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
|
||||
28 1 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan jushuitan:sync-shipping --days=3 >/dev/null 2>&1
|
||||
# 财务台账:每天 02:30 从 AI 库 rpt 同步动账明细并重算流水。
|
||||
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 定点把该渠道所用
|
||||
# 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。
|
||||
30 9 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_originalBillInfo --max-batches=20 --quiet >/dev/null 2>&1
|
||||
|
||||
32
deploy/clickhouse-resource-policy.xml
Normal file
32
deploy/clickhouse-resource-policy.xml
Normal file
@ -0,0 +1,32 @@
|
||||
<clickhouse>
|
||||
<!-- Keep diagnostics bounded on the shared 8-core / 16GB host. -->
|
||||
<logger><level>information</level></logger>
|
||||
<background_schedule_pool_size>32</background_schedule_pool_size>
|
||||
<total_memory_profiler_step>67108864</total_memory_profiler_step>
|
||||
<text_log>
|
||||
<level>information</level>
|
||||
<flush_interval_milliseconds>30000</flush_interval_milliseconds>
|
||||
<max_size_rows>16384</max_size_rows>
|
||||
<reserved_size_rows>1024</reserved_size_rows>
|
||||
<buffer_size_rows_flush_threshold>8192</buffer_size_rows_flush_threshold>
|
||||
<ttl>event_date + INTERVAL 7 DAY</ttl>
|
||||
</text_log>
|
||||
<trace_log>
|
||||
<flush_interval_milliseconds>30000</flush_interval_milliseconds>
|
||||
<max_size_rows>16384</max_size_rows>
|
||||
<reserved_size_rows>1024</reserved_size_rows>
|
||||
<buffer_size_rows_flush_threshold>8192</buffer_size_rows_flush_threshold>
|
||||
<ttl>event_date + INTERVAL 7 DAY</ttl>
|
||||
</trace_log>
|
||||
<metric_log>
|
||||
<collect_interval_milliseconds>10000</collect_interval_milliseconds>
|
||||
<flush_interval_milliseconds>60000</flush_interval_milliseconds>
|
||||
<ttl>event_date + INTERVAL 7 DAY</ttl>
|
||||
</metric_log>
|
||||
<asynchronous_metric_log>
|
||||
<flush_interval_milliseconds>60000</flush_interval_milliseconds>
|
||||
<ttl>event_date + INTERVAL 7 DAY</ttl>
|
||||
</asynchronous_metric_log>
|
||||
<part_log><ttl>event_date + INTERVAL 7 DAY</ttl></part_log>
|
||||
<query_log><ttl>event_date + INTERVAL 14 DAY</ttl></query_log>
|
||||
</clickhouse>
|
||||
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
|
||||
45
deploy/run-finance-batch.sh
Normal file
45
deploy/run-finance-batch.sh
Normal file
@ -0,0 +1,45 @@
|
||||
#!/bin/sh
|
||||
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|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"
|
||||
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
|
||||
caiwu_akaunting:*) IMAGE_TAG="${image#*:}" ;;
|
||||
*) echo 'Unsupported active finance image; no batch container started' >&2; exit 65 ;;
|
||||
esac
|
||||
RUN_MIGRATIONS=0
|
||||
export IMAGE_TAG RUN_MIGRATIONS
|
||||
# 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` 启动即报“拒绝访问”。
|
||||
@ -269,6 +280,23 @@ php artisan business-mirror:sync-tables
|
||||
|
||||
首次全量、历史回补和镜像校准由任务 Codex 自主执行:先备份受影响的系统配置、镜像表及同步状态,再在生产同步节点内使用现有上游互斥锁,按稳定游标分批执行或续传 `--full`,直至完成并核对源表/镜像和实际页面。无需负责人另行授权或代跑命令;开发机驱动不支持时直接通过 SSH 使用生产容器,不以本机不能同步为由停止任务。后续无参数命令只执行或继续受控增量,不对无关数据集批量全量。
|
||||
|
||||
重型发货回填、业绩明细重算、商品成本重算和店铺候选全量核对,统一从生产节点使用受限任务入口:
|
||||
|
||||
```bash
|
||||
sh /opt/caiwuxitong/run-finance-batch.sh jushuitan:sync-shipping --from=YYYY-MM-DD --to=YYYY-MM-DD
|
||||
sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:order-match:sync YYYY-MM --platform=taobao-alipay
|
||||
```
|
||||
|
||||
入口持有现有上游同步锁,使用 `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 网络代理。开发探索与正式页面的读取方式不同:
|
||||
|
||||
93
docs/财务台账开发说明.md
Normal file
93
docs/财务台账开发说明.md
Normal file
@ -0,0 +1,93 @@
|
||||
# 财务台账开发说明
|
||||
|
||||
财务台账的账户、店铺、公司、期初、动账明细、每日流水和账户映射统一落在
|
||||
`caiwu_business_mirror`,不放在遗留库 `caiwu_db`,也不放进系统配置库
|
||||
`caiwu_system_config`。
|
||||
|
||||
## 表
|
||||
|
||||
| 表 | 说明 |
|
||||
|---|---|
|
||||
| `fin_accounts` | 账户总表 |
|
||||
| `fin_shops` | 店铺台账 |
|
||||
| `fin_companies` | 公司台账 |
|
||||
| `fin_account_opening` | 账户期初余额 |
|
||||
| `fin_transactions` | 动账明细 |
|
||||
| `fin_daily_flows` | 每日资金流水 |
|
||||
| `fin_account_map` | 来源值 → 账户简称 |
|
||||
| `fin_account_map_events` | 映射修改审计 |
|
||||
|
||||
迁移:`database/migrations/2026_10_19_160000_create_finance_ledger_tables.php`。
|
||||
|
||||
## 本地导入
|
||||
|
||||
基础数据和期初来自财务维护的《银行当前余额-*.xlsx》。先把文件复制进应用容器:
|
||||
|
||||
```bash
|
||||
docker cp "/path/银行当前余额-20261018-3.xlsx" jiangling_app:/tmp/finance-ledger-base.xlsx
|
||||
docker exec -u root jiangling_app chmod 644 /tmp/finance-ledger-base.xlsx
|
||||
docker compose -f docker-compose.local.yml exec -u www-data app \
|
||||
php artisan finance-ledger:import-base /tmp/finance-ledger-base.xlsx --recalculate
|
||||
```
|
||||
|
||||
动账明细从 AI 库 `JSReportReplica.rpt` 读取:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml exec -u www-data app \
|
||||
php artisan finance-ledger:sync-rpt
|
||||
```
|
||||
|
||||
本地尚未接入共享系统数据库时,可在 `runtime/.env` 临时启用
|
||||
`UPSTREAM_LOCAL_CONFIG_ENABLED=true` 并配置 `DB_OPERATIONS_REPLICA_*`;
|
||||
该文件已被 Git 忽略。生产环境使用共享系统数据库中的只读连接,不需要
|
||||
在本机或服务器保存上游密码。
|
||||
|
||||
## 校验与重算
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml exec -u www-data app \
|
||||
php artisan finance-ledger:recalculate
|
||||
|
||||
docker compose -f docker-compose.local.yml exec -u www-data app \
|
||||
php artisan finance-ledger:verify
|
||||
```
|
||||
|
||||
`finance-ledger:verify` 检查账户/店铺/公司/期初/明细/映射数量、期初与余额合计、
|
||||
以及“期末 = 期初 + 入账 − 出账”的勾稽差异。
|
||||
|
||||
## 页面
|
||||
|
||||
- 路由:`/workspace/financial-ledger/ledger`
|
||||
- 页面:账户总表、店铺台账、每日资金流水、动账明细、账户映射、数据来源
|
||||
- 写权限:账户映射修改仅系统管理员;其他账号只读
|
||||
- 调度:`finance-ledger:sync-rpt` 每天 02:30(Asia/Shanghai)
|
||||
|
||||
生产宿主机 `/etc/cron.d` 仍需增加每日调用入口;本项目当前没有运行
|
||||
`schedule:run` 的容器,调度登记不能替代宿主机 cron。
|
||||
|
||||
## 数据安全
|
||||
|
||||
真实期初、账户余额和动账明细不得提交到 Git。本地导入文件放在仓库外的
|
||||
私有目录,或使用被忽略的 `runtime/private/`;测试使用合成数据。
|
||||
|
||||
## 上线前检查
|
||||
|
||||
1. 备份 `caiwu_system_config` 和 `caiwu_business_mirror`;
|
||||
2. 发布新镜像并执行 `fin_*` 迁移(本次只新增表,不改已有表);
|
||||
3. 首次在生产执行:
|
||||
|
||||
```bash
|
||||
docker exec -u www-data caiwu_app php artisan finance-ledger:import-base /path/银行当前余额-*.xlsx --recalculate
|
||||
docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt
|
||||
docker exec -u www-data caiwu_app php artisan finance-ledger:verify
|
||||
```
|
||||
|
||||
4. 在宿主机 `/etc/cron.d/caiwu-jiansun-snapshots` 增加:
|
||||
|
||||
```cron
|
||||
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
|
||||
```
|
||||
|
||||
5. 在「系统设置 → 权限」把「财务台账」页面分配给财务角色;账户映射修改允许超级管理员和「财务台账管理员」角色(可用 `FINANCE_LEDGER_MAPPING_EDITOR_ROLES` 覆盖)。
|
||||
|
||||
生产环境不设置 `FINANCE_LEDGER_WINDOW_END` 时会同步到当天;本地为了复现 12,037 笔规格数据,在 `runtime/.env` 固定为 `2026-10-18`。
|
||||
@ -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,23 +1293,33 @@ 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 {
|
||||
$rows = $this->connection->table('js_jst_work_order_info')
|
||||
->where('title', '查件')
|
||||
->where(function ($query) use ($chunk): void {
|
||||
foreach ($chunk as $waybill) {
|
||||
$query->orWhere('businessFieldList', 'like', '%' . str_replace(['%', '_'], '', $waybill) . '%');
|
||||
}
|
||||
})
|
||||
->orderByDesc('Id')
|
||||
->get(['businessFieldList']);
|
||||
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 {
|
||||
foreach ($chunk as $waybill) {
|
||||
$query->orWhere('businessFieldList', 'like', '%' . str_replace(['%', '_'], '', $waybill) . '%');
|
||||
}
|
||||
})
|
||||
->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());
|
||||
return [
|
||||
'shops' => (clone $q)->distinct()->orderBy('store_name')->pluck('store_name')->all(),
|
||||
'bill_items' => (clone $q)->distinct()->orderBy('bill_item')->pluck('bill_item')->all(),
|
||||
];
|
||||
$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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ namespace Modules\EcommerceFinance\Repositories;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use Throwable;
|
||||
|
||||
@ -13,6 +14,8 @@ class OrderDataMirrorRepository
|
||||
/** 完整店铺候选的缓存键(页面店铺多选使用)。 */
|
||||
public const SHOP_NAMES_CACHE_KEY = 'order-data.shop-candidates:v2';
|
||||
|
||||
public const SHOP_NAMES_CURSORS_KEY = 'order-data.shop-candidates:cursors:v1';
|
||||
|
||||
/** 订单号搜索一次最多解析出多少候选订单,超过就退回模糊匹配。 */
|
||||
private const ORDER_NUMBER_CANDIDATE_LIMIT = 500;
|
||||
|
||||
@ -67,21 +70,26 @@ class OrderDataMirrorRepository
|
||||
/**
|
||||
* 重新计算并缓存完整店铺候选(店铺目录 ∪ 订单镜像里出现过的店铺)。
|
||||
*
|
||||
* 只允许定时任务调用:单次要扫两张百万级宽表。
|
||||
* 按镜像同步时间增量补充;夜间 --full 对账移除已不存在的候选。
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function refreshShopNamesCache(): array
|
||||
public function refreshShopNamesCache(bool $full = false): array
|
||||
{
|
||||
$cached = Cache::get(self::SHOP_NAMES_CACHE_KEY);
|
||||
$incremental = ! $full && is_array($cached);
|
||||
$cursors = $incremental ? (array) Cache::get(self::SHOP_NAMES_CURSORS_KEY, []) : [];
|
||||
[$changed, $nextCursors] = $this->computedShopNames($incremental, $cursors);
|
||||
$names = array_values(array_unique(array_merge(
|
||||
$this->computedShopNames(),
|
||||
$incremental ? $cached : [],
|
||||
$changed,
|
||||
$this->shopDirectoryNames(),
|
||||
)));
|
||||
sort($names, SORT_STRING);
|
||||
|
||||
if ($names !== []) {
|
||||
Cache::put(self::SHOP_NAMES_CACHE_KEY, $names, now()->addHours(24));
|
||||
}
|
||||
// Empty is also a valid full reconciliation result.
|
||||
Cache::put(self::SHOP_NAMES_CACHE_KEY, $names, now()->addHours(24));
|
||||
Cache::put(self::SHOP_NAMES_CURSORS_KEY, $nextCursors, now()->addHours(24));
|
||||
|
||||
return $names;
|
||||
}
|
||||
@ -184,22 +192,34 @@ class OrderDataMirrorRepository
|
||||
/**
|
||||
* 非淘系 + 淘系两个来源合并去重(淘系店铺名形如"天猫-xx/淘宝-xx")。
|
||||
*
|
||||
* 分开各自 DISTINCT 再在 PHP 合并:UNION 会让 MariaDB 把两个上百万行的结果
|
||||
* 一起物化去重再排序,单次要两三分钟。只在定时预热时执行。
|
||||
*
|
||||
* @return array<int,string>
|
||||
* @param array<string,string|null> $cursors
|
||||
* @return array{0:list<string>,1:array<string,string|null>}
|
||||
*/
|
||||
private function computedShopNames(): array
|
||||
private function computedShopNames(bool $incremental, array $cursors): array
|
||||
{
|
||||
$names = [];
|
||||
$nextCursors = [];
|
||||
|
||||
foreach (['ops_jst_orders', 'ops_jst_qmcust_orders'] as $table) {
|
||||
$rows = $this->connection->table($table)
|
||||
$hasTimestamp = $this->connection->getSchemaBuilder()->hasColumn($table, '_mirror_synced_at');
|
||||
$high = $hasTimestamp ? $this->connection->table($table)->max('_mirror_synced_at') : null;
|
||||
$since = $cursors[$table] ?? null;
|
||||
if ($incremental && $since === null && $high !== null) {
|
||||
// Upgrade the existing 24-hour complete cache without another full scan.
|
||||
$since = Carbon::parse($high)->subDay()->format('Y-m-d H:i:s');
|
||||
}
|
||||
$query = $this->connection->table($table)
|
||||
->select('shop_name')
|
||||
->distinct()
|
||||
->whereNotNull('shop_name')
|
||||
->where('shop_name', '<>', '')
|
||||
->pluck('shop_name');
|
||||
->where('shop_name', '<>', '');
|
||||
if ($incremental && $hasTimestamp && $since !== null && $high !== null) {
|
||||
// Inclusive lower bound retains writes sharing the same timestamp.
|
||||
$query->where('_mirror_synced_at', '>=', $since)
|
||||
->where('_mirror_synced_at', '<=', $high);
|
||||
}
|
||||
$rows = $query->pluck('shop_name');
|
||||
$nextCursors[$table] = $high;
|
||||
|
||||
foreach ($this->normalizeShopNames($rows) as $name) {
|
||||
$names[$name] = true;
|
||||
@ -209,7 +229,7 @@ class OrderDataMirrorRepository
|
||||
$names = array_keys($names);
|
||||
sort($names, SORT_STRING);
|
||||
|
||||
return $names;
|
||||
return [$names, $nextCursors];
|
||||
}
|
||||
|
||||
/** @param iterable<mixed> $rows @return array<int,string> */
|
||||
@ -475,12 +495,18 @@ class OrderDataMirrorRepository
|
||||
$shopNames = $this->selectedShopNames($filters);
|
||||
|
||||
if ($shopNames !== []) {
|
||||
// 多店铺:每个店铺做一个模糊匹配再取并集,选中的是完整店铺名时等同于精确匹配。
|
||||
$query->where(function (Builder $shop) use ($shopNames): void {
|
||||
foreach ($shopNames as $name) {
|
||||
$shop->orWhereRaw("o.shop_name LIKE ? ESCAPE '!'", [$this->contains($name)]);
|
||||
}
|
||||
});
|
||||
// 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'] ?? '') !== '') {
|
||||
// 商品编码同样支持批量:每个编码一组"商品/款式/店铺编码"匹配,多组取并集。
|
||||
|
||||
@ -0,0 +1,367 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use InvalidArgumentException;
|
||||
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 聚水潭聚工单「打款工单 → 种菜报销」:只读工单镜像 jiansun.dbo.jst_workOrderInfo。
|
||||
*
|
||||
* 口径(2026-10-19 财务确认):工单类型“种菜报销”、工单状态“已完成”,按完成时间筛选,
|
||||
* 自 2026-09-02 00:00 起接入。种菜报销都是当天报销前一天的明细,所以 M 月种菜报销明细
|
||||
* = 完成时间 [M 月 2 日 00:00, M+1 月 2 日 00:00)。同一工单号只取最新一版。
|
||||
*/
|
||||
class PlantingReimbursementRepository
|
||||
{
|
||||
public const DATASET = 'jiansun.dbo.jst_workOrderInfo';
|
||||
|
||||
public const WORK_TYPE_ID = '6F03128403F2442CABCF283B59FAD88D';
|
||||
|
||||
public const STATUS_CONFIRMED = 'CONFIRMED';
|
||||
|
||||
public const START_AT = '2026-09-02 00:00:00';
|
||||
|
||||
public const FIRST_MONTH = '2026-09';
|
||||
|
||||
/** 聚工单只返回 fieldId;对应关系由历史种菜明细逐列对撞及打款工单列表核对得出。 */
|
||||
public const FIELDS = [
|
||||
'online_order_no' => 'orderField',
|
||||
'internal_order_no' => 'o_id',
|
||||
'platform' => 'platform',
|
||||
'shop_name' => 'shopField',
|
||||
'actual_paid_amount' => 'actualPaidAmountField',
|
||||
'task_remark' => 'TEXTField1413856620251113150546',
|
||||
'seller_remark' => 'businessRemarkField',
|
||||
'task_payment' => 'NUMBERField1413856620250924131615',
|
||||
'task_freight' => 'NUMBERField1413856620251113150537',
|
||||
'task_commission' => 'NUMBERField1413856620251230154457',
|
||||
'jingxi_price_diff' => 'NUMBERField1413856620250924131623',
|
||||
'task_deduction' => 'NUMBERField1413856620251113150612',
|
||||
'task_empty_package_fee' => 'NUMBERField1413856620251113145620',
|
||||
'reimbursement_amount' => 'NUMBERField1413856620251117101250',
|
||||
'payee_name' => 'TEXTField1413856620251117180931',
|
||||
'payee_account' => 'TEXTField1413856620251117113451',
|
||||
'payee_bank' => 'TEXTField1413856620251117112729',
|
||||
'payee_mobile' => 'TEXTField1413856620260618171005',
|
||||
'erp_order_status' => 'erpOrderStatus',
|
||||
'platform_order_status' => 'platformOrderStatus',
|
||||
'paid_at' => 'payDateField',
|
||||
];
|
||||
|
||||
public const MONEY_FIELDS = [
|
||||
'actual_paid_amount', 'task_payment', 'task_freight', 'task_commission', 'jingxi_price_diff',
|
||||
'task_deduction', 'task_empty_package_fee', 'reimbursement_amount',
|
||||
];
|
||||
|
||||
private const ERP_ORDER_STATUS = [
|
||||
'Sent' => '已发货', 'Merged' => '被合并', 'Cancelled' => '已取消', 'Split' => '被拆分',
|
||||
'WaitConfirm' => '待审核', 'WaitFConfirm' => '待财审', 'Delivering' => '发货中',
|
||||
'Question' => '异常', 'WaitOuterSent' => '等供销商发货',
|
||||
];
|
||||
|
||||
private const ROWS_CACHE_SECONDS = 600;
|
||||
|
||||
private const QUERY_TIMEOUT_SECONDS = 90;
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
) {
|
||||
$this->connection = $mirror->connection();
|
||||
}
|
||||
|
||||
/**
|
||||
* 报销月份 → 完成时间窗口 [M-02 00:00, M+1-02 00:00)。
|
||||
*
|
||||
* @return array{from:string,to:string}
|
||||
*/
|
||||
public static function window(string $month): array
|
||||
{
|
||||
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) !== 1 || $month < self::FIRST_MONTH) {
|
||||
throw new InvalidArgumentException('报销月份无效。');
|
||||
}
|
||||
$start = CarbonImmutable::createFromFormat('!Y-m-d', $month . '-02');
|
||||
|
||||
return ['from' => $start->format('Y-m-d H:i:s'), 'to' => $start->addMonthNoOverflow()->format('Y-m-d H:i:s')];
|
||||
}
|
||||
|
||||
/** 默认月份:完成时间窗口已开始的最近一个报销月(每月 1 日仍算上月)。 */
|
||||
public static function defaultMonth(?CarbonImmutable $now = null): string
|
||||
{
|
||||
$now ??= CarbonImmutable::now();
|
||||
$month = $now->subDay()->format('Y-m');
|
||||
|
||||
return max($month, self::FIRST_MONTH);
|
||||
}
|
||||
|
||||
/** @return list<string> 可选报销月份(新到旧) */
|
||||
public static function months(?CarbonImmutable $now = null): array
|
||||
{
|
||||
$months = [];
|
||||
$cursor = CarbonImmutable::createFromFormat('!Y-m', self::defaultMonth($now));
|
||||
while ($cursor->format('Y-m') >= self::FIRST_MONTH) {
|
||||
$months[] = $cursor->format('Y-m');
|
||||
$cursor = $cursor->subMonthNoOverflow();
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
/** @return array{code:string,last_succeeded_at:?string} */
|
||||
public function syncStatus(): array
|
||||
{
|
||||
try {
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
if (! $schema->hasTable('business_mirror_table_states') || ! $schema->hasTable($this->dataset()->targetTable())) {
|
||||
return ['code' => 'unavailable', 'last_succeeded_at' => null];
|
||||
}
|
||||
$state = $this->mirror->state($this->dataset());
|
||||
} catch (Throwable) {
|
||||
return ['code' => 'unavailable', 'last_succeeded_at' => null];
|
||||
}
|
||||
if (! is_array($state)) {
|
||||
return ['code' => 'unavailable', 'last_succeeded_at' => null];
|
||||
}
|
||||
|
||||
$last = isset($state['last_succeeded_at']) ? (string) $state['last_succeeded_at'] : null;
|
||||
if (($state['initial_completed_at'] ?? null) === null) {
|
||||
return ['code' => ($state['status'] ?? '') === 'failed' ? 'initial_failed' : 'initializing', 'last_succeeded_at' => $last];
|
||||
}
|
||||
|
||||
return ['code' => ($state['status'] ?? '') === 'failed' ? 'failed' : 'ready', 'last_succeeded_at' => $last];
|
||||
}
|
||||
|
||||
/**
|
||||
* 某报销月份的全部种菜报销(已完成,每个工单号取最新一版),按完成时间倒序。
|
||||
* 结果按月份缓存 10 分钟;单月约 1.5~2 万行。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function rows(string $month): array
|
||||
{
|
||||
$window = self::window($month);
|
||||
$status = $this->syncStatus();
|
||||
if (! in_array($status['code'], ['ready', 'failed'], true)) {
|
||||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||||
}
|
||||
|
||||
// 镜像每几分钟增量一次,按版本做键会让缓存几乎每次失效;固定按月份缓存 10 分钟。
|
||||
$key = 'planting-reimbursements:rows:v2:' . $month;
|
||||
$cached = Cache::get($key);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$rows = $this->load($window['from'], $window['to']);
|
||||
try {
|
||||
Cache::put($key, $rows, self::ROWS_CACHE_SECONDS);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function load(string $from, string $to): array
|
||||
{
|
||||
$table = $this->dataset()->targetTable();
|
||||
$query = $this->connection->table($table)
|
||||
->where('woTypeId', self::WORK_TYPE_ID)
|
||||
->where('completeTime', '>=', $from)
|
||||
->where('completeTime', '<', $to)
|
||||
->select([
|
||||
'Id', 'woNo', 'status', 'createby', 'executors', 'completer',
|
||||
'completeTime', 'created', 'businessFieldList',
|
||||
])
|
||||
->orderByDesc('completeTime')
|
||||
->orderByDesc('woNo');
|
||||
|
||||
/** @var array<string,object> $latest 窗口内每个工单号的最新一版 */
|
||||
$latest = [];
|
||||
foreach ($this->boundedGet($query) as $record) {
|
||||
$woNo = (string) $record->woNo;
|
||||
if (! isset($latest[$woNo]) || (int) $record->Id > (int) $latest[$woNo]->Id) {
|
||||
$latest[$woNo] = $record;
|
||||
}
|
||||
}
|
||||
|
||||
// 同一工单若有更新的一版(可能已改完成时间或被作废)落在窗口外,以最新一版为准,这里剔除。
|
||||
$newestIds = [];
|
||||
foreach (array_chunk(array_map('strval', array_keys($latest)), 1000) as $chunk) {
|
||||
$versions = $this->connection->table($table)
|
||||
->where('woTypeId', self::WORK_TYPE_ID)
|
||||
->whereIn('woNo', $chunk)
|
||||
->groupBy('woNo')
|
||||
->selectRaw('woNo, MAX(Id) AS newest_id')
|
||||
->get();
|
||||
foreach ($versions as $version) {
|
||||
$newestIds[(string) $version->woNo] = (int) $version->newest_id;
|
||||
}
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($latest as $woNo => $record) {
|
||||
if (($newestIds[(string) $woNo] ?? (int) $record->Id) > (int) $record->Id) {
|
||||
continue;
|
||||
}
|
||||
// 最新一版已不是“已完成”(如完成后被作废)的工单不计入。
|
||||
if ((string) $record->status !== self::STATUS_CONFIRMED) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = $this->decorate($record);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function decorate(object $record): array
|
||||
{
|
||||
$fields = $this->fields((string) ($record->businessFieldList ?? ''));
|
||||
$row = [
|
||||
'work_order_no' => (string) $record->woNo,
|
||||
'started_at' => $this->time($record->created ?? null),
|
||||
'completed_at' => $this->time($record->completeTime ?? null),
|
||||
'status' => '已完成',
|
||||
'initiator' => (string) ($record->createby ?? ''),
|
||||
'executor' => (string) ($record->executors ?? ''),
|
||||
'completer' => (string) ($record->completer ?? ''),
|
||||
];
|
||||
foreach (self::FIELDS as $key => $fieldId) {
|
||||
$row[$key] = $this->display($fields[$fieldId] ?? null);
|
||||
}
|
||||
foreach (self::MONEY_FIELDS as $key) {
|
||||
$row[$key] = $this->money($row[$key]);
|
||||
}
|
||||
$row['erp_order_status'] = self::ERP_ORDER_STATUS[$row['erp_order_status']] ?? $row['erp_order_status'];
|
||||
|
||||
$items = $this->items($fields['itemInfo']['raw'] ?? null);
|
||||
$row['product_code'] = implode("\n", array_filter(array_column($items, 'skuId')));
|
||||
$row['product_name'] = implode("\n", array_filter(array_column($items, 'name')));
|
||||
$row['product_quantity'] = implode("\n", array_filter(array_column($items, 'qty')));
|
||||
$row['platform_product_id'] = implode("\n", array_filter(array_column($items, 'platformItemId')));
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/** @return array<string,array{value:mixed,label:mixed,raw:array<mixed>}> */
|
||||
private function fields(string $json): array
|
||||
{
|
||||
if (trim($json) === '') {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$decoded = json_decode($json, true, 64, JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
if (isset($decoded['businessField']) && is_array($decoded['businessField'])) {
|
||||
$decoded = $decoded['businessField'];
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
foreach (is_array($decoded) ? $decoded : [] as $item) {
|
||||
if (is_array($item) && isset($item['fieldId'])) {
|
||||
$fields[(string) $item['fieldId']] = [
|
||||
'value' => $item['value'] ?? ($item['valueList'] ?? null),
|
||||
'label' => $item['valueLabel'] ?? null,
|
||||
'raw' => $item,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/** @param array{value:mixed,label:mixed}|null $field */
|
||||
private function display(?array $field): string
|
||||
{
|
||||
if ($field === null) {
|
||||
return '';
|
||||
}
|
||||
foreach ([$field['label'], $field['value']] as $value) {
|
||||
if (is_array($value)) {
|
||||
$value = implode('、', array_filter(array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $value)));
|
||||
}
|
||||
if (is_scalar($value) && trim((string) $value) !== '') {
|
||||
return trim((string) $value);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed>|null $raw
|
||||
* @return list<array<string,string>>
|
||||
*/
|
||||
private function items(?array $raw): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ((array) ($raw['childFieldList'] ?? []) as $childRow) {
|
||||
$item = [];
|
||||
foreach ((array) $childRow as $child) {
|
||||
if (is_array($child) && isset($child['fieldId'])) {
|
||||
$v = $child['valueLabel'] ?? $child['value'] ?? '';
|
||||
$item[(string) $child['fieldId']] = is_scalar($v) ? trim((string) $v) : '';
|
||||
}
|
||||
}
|
||||
if ($item !== []) {
|
||||
$items[] = $item;
|
||||
}
|
||||
}
|
||||
foreach ((array) ($raw['value'] ?? []) as $item) {
|
||||
if (is_array($item) && ! array_is_list($item)) {
|
||||
$items[] = array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $item);
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function money(string $value): ?string
|
||||
{
|
||||
$normalized = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim($value));
|
||||
if (! preg_match('/^-?\d+(\.\d+)?$/', $normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return number_format(round((float) $normalized, 2), 2, '.', '');
|
||||
}
|
||||
|
||||
private function time(mixed $value): string
|
||||
{
|
||||
return substr(trim((string) $value), 0, 19);
|
||||
}
|
||||
|
||||
/** @return iterable<int,object> */
|
||||
private function boundedGet(Builder $query): iterable
|
||||
{
|
||||
$connection = $query->getConnection();
|
||||
if ($connection->getDriverName() !== 'mysql') {
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
return $connection->select(
|
||||
'SET STATEMENT max_statement_time=' . self::QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(),
|
||||
$query->getBindings(),
|
||||
);
|
||||
}
|
||||
|
||||
private function dataset(): BusinessMirrorDataset
|
||||
{
|
||||
return $this->datasets->get(self::DATASET);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -16,8 +16,9 @@ use RuntimeException;
|
||||
* 失效时间为变更日前一天、新主体从变更日当天生效,收入按实际日期归集,不重复统计。
|
||||
*
|
||||
* 两列金额口径不同,页面分别展示:
|
||||
* 销量(period_sales / sales_NN):业绩明细(店铺收款匹配订单明细)的「订单实付/实收金额」,
|
||||
* 按「店铺名称 + 发生时间」预汇总到 store_report_paid_sales_rows,再按发生日期落到主体期间;
|
||||
* 销量(period_sales / sales_NN):业绩明细(店铺收款匹配订单明细)的
|
||||
* 「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按「店铺名称 + 发生日期」
|
||||
* 预汇总到 store_report_paid_sales_rows,再按发生日期落到主体期间;
|
||||
* 收入(rolling_12m_income / income_NN):平台账单日汇总 store_report_daily_rows 的收入,
|
||||
* 供主体维度经营报表按主体汇总使用。
|
||||
*/
|
||||
@ -28,7 +29,7 @@ class StoreReportRepository
|
||||
public const LIFECYCLES_DATASET = 'store-report.store-lifecycles';
|
||||
/** 平台账单日汇总:主体维度经营报表的「收入」列仍按这里取数。 */
|
||||
public const DAILY_DATASET = 'store-report.daily-sales';
|
||||
/** 销量数据集:业绩明细「订单实付/实收金额」按「店铺名称 + 发生日期」预汇总。 */
|
||||
/** 销量数据集:业绩明细四项金额合计按「店铺名称 + 发生日期」预汇总。 */
|
||||
public const PAID_SALES_DATASET = 'store-report.paid-sales';
|
||||
/** 销量汇总表;由业绩明细表汇总而来,页面按店铺名与发生日期关联主体期间。 */
|
||||
public const PAID_SALES_TABLE = 'store_report_paid_sales_rows';
|
||||
@ -36,7 +37,8 @@ class StoreReportRepository
|
||||
public const PAID_SALES_SOURCE_TABLE = 'store_receipt_order_match_rows';
|
||||
|
||||
/**
|
||||
* 销量来源:业绩明细的「订单实付/实收金额」(按店铺 + 发生日期预汇总)。
|
||||
* 销量来源:收入+优惠+冲减+退款四项合计(按店铺 + 发生日期预汇总)。
|
||||
* 列名沿用历史命名 paid_amount,避免共享镜像表结构变更影响线上旧版本。
|
||||
*
|
||||
* @var array{table:string,amount:string}
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 科目映射表变更后,把受影响的「业绩明细」平台×月份放进后台重跑队列。
|
||||
*
|
||||
* 业绩明细的优惠/冲减/退款金额是同步时按映射表算好落库的,改映射必须重跑对应
|
||||
* 平台×月份才会更新;这里把重跑做成后台自动执行,页面保存后无需人工执行命令。
|
||||
*/
|
||||
class AccountMappingResyncDispatcher
|
||||
{
|
||||
public const JOBS_TABLE = 'ec_account_mapping_resync_jobs';
|
||||
|
||||
/**
|
||||
* 科目映射表的平台名 → [同步命令 platform 键, 业绩明细平台名]。
|
||||
* 只保留已接入业绩同步的平台;其它平台(如唯品会JX)没有同步任务。
|
||||
*/
|
||||
private const PLATFORM_MAP = [
|
||||
'淘宝' => [['taobao-alipay', '淘系支付宝'], ['taobao-wechat', '淘系微信']],
|
||||
'天猫' => [['taobao-alipay', '淘系支付宝'], ['taobao-wechat', '淘系微信']],
|
||||
'阿里巴巴' => [['taobao-alipay', '淘系支付宝']],
|
||||
'拼多多' => [['pinduoduo', '拼多多']],
|
||||
'京喜' => [['jingxi', '京喜账单']],
|
||||
'京东' => [['jd-transactions', '京东账单'], ['jd-wallet', '京东网银钱包']],
|
||||
'抖音' => [['douyin', '抖音']],
|
||||
'得物' => [['dewu', '得物']],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param list<string> $mappingPlatforms 科目映射表里的平台名
|
||||
* @return array{jobs:int}
|
||||
*/
|
||||
public function dispatchFor(array $mappingPlatforms): array
|
||||
{
|
||||
$targets = [];
|
||||
foreach ($mappingPlatforms as $platform) {
|
||||
foreach (self::PLATFORM_MAP[trim((string) $platform)] ?? [] as [$key, $label]) {
|
||||
$targets[$key] = $label;
|
||||
}
|
||||
}
|
||||
if ($targets === []) {
|
||||
return ['jobs' => 0];
|
||||
}
|
||||
|
||||
$mirror = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$periods = $mirror->table('store_receipt_order_match_rows')
|
||||
->whereIn('platform', array_values($targets))
|
||||
->select(['platform', 'report_month'])
|
||||
->distinct()
|
||||
->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,于是会紧接着再跑一遍,避免漏掉刚保存的新规则。
|
||||
$affected = DB::table(self::JOBS_TABLE)->updateOrInsert(
|
||||
['platform' => (string) $key, 'month' => (string) $month],
|
||||
[
|
||||
'status' => 'pending',
|
||||
'message' => null,
|
||||
'updated_at' => $now,
|
||||
'created_at' => $now,
|
||||
]
|
||||
);
|
||||
if ($affected) {
|
||||
$jobs++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($jobs > 0) {
|
||||
$this->spawnWorker();
|
||||
}
|
||||
|
||||
return ['jobs' => $jobs];
|
||||
}
|
||||
|
||||
/** @return array{jobs:int} 所有已接入平台一起重跑(用于「恢复初始 112 条」) */
|
||||
public function dispatchAll(): array
|
||||
{
|
||||
return $this->dispatchFor(array_keys(self::PLATFORM_MAP));
|
||||
}
|
||||
|
||||
/**
|
||||
* 起一个后台进程处理队列。
|
||||
*
|
||||
* 已有进程在跑时新进程会拿不到锁直接退出;正在跑的进程处理完当前任务后会继续取新任务。
|
||||
*/
|
||||
public function spawnWorker(): void
|
||||
{
|
||||
// Production cron consumes the durable queue in the resource-limited batch profile.
|
||||
if (config('upstream_pipeline.batch_worker_enabled', false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->launchWorker();
|
||||
}
|
||||
|
||||
protected function launchWorker(): void
|
||||
{
|
||||
try {
|
||||
$php = escapeshellarg(PHP_BINARY ?: 'php');
|
||||
$command = 'cd ' . escapeshellarg(base_path())
|
||||
. ' && nohup ' . $php . ' artisan store-receipt:auto-resync >/dev/null 2>&1 &';
|
||||
|
||||
Process::fromShellCommandline($command, base_path())
|
||||
->disableOutput()
|
||||
->setTimeout(10)
|
||||
->run();
|
||||
} catch (Throwable $exception) {
|
||||
// 后台进程起不来时任务仍留在队列里,可手动执行 store-receipt:auto-resync 补跑。
|
||||
Log::warning('科目映射表自动重跑进程启动失败:' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -34,7 +34,7 @@ class AccountMappingStore
|
||||
public const EVENTS_TABLE = 'ec_account_mapping_events';
|
||||
|
||||
/** 可维护字段(顺序即导出/模板列顺序) */
|
||||
public const FIELDS = ['platform', 'item', 'memo', 'goods', 'invoice', 'category', 'dAcct', 'dSub', 'cAcct', 'cSub', 'basis'];
|
||||
public const FIELDS = ['platform', 'item', 'memo', 'goods', 'invoice', 'category', 'feeMemo', 'dAcct', 'dSub', 'cAcct', 'cSub', 'basis'];
|
||||
|
||||
public const LABELS = [
|
||||
'platform' => '平台',
|
||||
@ -43,6 +43,7 @@ class AccountMappingStore
|
||||
'goods' => '商品名称关键词',
|
||||
'invoice' => '票据口径',
|
||||
'category' => '财务类型',
|
||||
'feeMemo' => '费用备注',
|
||||
'dAcct' => '借方科目',
|
||||
'dSub' => '借方二级',
|
||||
'cAcct' => '贷方科目',
|
||||
@ -55,6 +56,14 @@ class AccountMappingStore
|
||||
|
||||
public const BASISES = [self::BASIS_OUT, self::BASIS_IN];
|
||||
|
||||
/** 进程内规则缓存(写入时清空;CLI 批量任务可避免逐行重查规则表)。 */
|
||||
private static ?array $allMemo = null;
|
||||
|
||||
private static function forgetMemo(): void
|
||||
{
|
||||
self::$allMemo = null;
|
||||
}
|
||||
|
||||
/** 大类 → 预期方向基准(用于校验「基准与大类是否一致」) */
|
||||
public const CATEGORY_BASIS = [
|
||||
'费用' => self::BASIS_OUT,
|
||||
@ -101,13 +110,19 @@ class AccountMappingStore
|
||||
/** @return array<int,array<string,mixed>> 按 id 升序的全部规则(共享数据库优先,表不存在时退回旧文件) */
|
||||
public function all(): array
|
||||
{
|
||||
// 进程内缓存:业绩明细等批量任务会逐行调用匹配器,而匹配器每次都走 all(),
|
||||
// 不缓存时每个账单行都要重查一次规则表(36k 行会拖到几十分钟)。写入方法会清空缓存。
|
||||
if (self::$allMemo !== null) {
|
||||
return self::$allMemo;
|
||||
}
|
||||
|
||||
if ($this->rulesTableReady()) {
|
||||
$out = [];
|
||||
foreach (DB::table(self::RULES_TABLE)->orderBy('id')->get() as $row) {
|
||||
$out[] = $this->fromRow($row);
|
||||
}
|
||||
|
||||
return $out;
|
||||
return self::$allMemo = $out;
|
||||
}
|
||||
|
||||
// 兼容:迁移前的环境(或回滚场景)继续读文件
|
||||
@ -119,7 +134,7 @@ class AccountMappingStore
|
||||
$rules = array_values(array_filter($rules, static fn (mixed $r): bool => is_array($r) && isset($r['id'])));
|
||||
usort($rules, static fn (array $a, array $b): int => ((int) $a['id']) <=> ((int) $b['id']));
|
||||
|
||||
return $rules;
|
||||
return self::$allMemo = $rules;
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
@ -136,6 +151,7 @@ class AccountMappingStore
|
||||
/** 新增一条,返回新规则 */
|
||||
public function create(array $values, ?int $userId = null): array
|
||||
{
|
||||
self::forgetMemo();
|
||||
$rules = $this->all();
|
||||
$id = 1;
|
||||
foreach ($rules as $rule) {
|
||||
@ -153,6 +169,7 @@ class AccountMappingStore
|
||||
/** 修改一条,返回改后的规则 */
|
||||
public function update(int $id, array $values, ?int $userId = null): array
|
||||
{
|
||||
self::forgetMemo();
|
||||
$rules = $this->all();
|
||||
$updated = null;
|
||||
$before = null;
|
||||
@ -171,12 +188,24 @@ class AccountMappingStore
|
||||
throw new RuntimeException('未找到要修改的规则。');
|
||||
}
|
||||
|
||||
$this->persist($rules);
|
||||
$this->logEvent('update', $id, $before, $updated, $userId);
|
||||
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();
|
||||
@ -205,10 +234,11 @@ 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
|
||||
{
|
||||
self::forgetMemo();
|
||||
$rules = $this->all();
|
||||
$byKey = [];
|
||||
foreach ($rules as $index => $rule) {
|
||||
@ -224,6 +254,7 @@ class AccountMappingStore
|
||||
$skipped = 0;
|
||||
$duplicates = [];
|
||||
|
||||
$resyncPlatforms = [];
|
||||
foreach ($rows as $row) {
|
||||
$values = $this->normalize($row);
|
||||
if ($this->isEmptyRow($values)) {
|
||||
@ -238,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;
|
||||
}
|
||||
@ -247,6 +287,7 @@ class AccountMappingStore
|
||||
$byKey[$key] = count($rules) - 1;
|
||||
$nextId++;
|
||||
$created++;
|
||||
$resyncPlatforms[$values['platform']] = true;
|
||||
}
|
||||
|
||||
if ($created + $updated > 0) {
|
||||
@ -264,6 +305,7 @@ class AccountMappingStore
|
||||
'skipped' => $skipped,
|
||||
'duplicates' => array_slice($duplicates, 0, 20),
|
||||
'total' => count($rows),
|
||||
'resync_platforms' => array_keys($resyncPlatforms),
|
||||
];
|
||||
}
|
||||
|
||||
@ -492,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);
|
||||
|
||||
@ -609,6 +652,7 @@ class AccountMappingStore
|
||||
'goods' => (string) $row->goods,
|
||||
'invoice' => (string) $row->invoice,
|
||||
'category' => (string) $row->category,
|
||||
'feeMemo' => (string) ($row->fee_memo ?? ''),
|
||||
'dAcct' => (string) $row->d_acct,
|
||||
'dSub' => (string) $row->d_sub,
|
||||
'cAcct' => (string) $row->c_acct,
|
||||
@ -631,6 +675,7 @@ class AccountMappingStore
|
||||
'goods' => $rule['goods'],
|
||||
'invoice' => $rule['invoice'],
|
||||
'category' => $rule['category'],
|
||||
'fee_memo' => $rule['feeMemo'],
|
||||
'd_acct' => $rule['dAcct'],
|
||||
'd_sub' => $rule['dSub'],
|
||||
'c_acct' => $rule['cAcct'],
|
||||
|
||||
@ -26,6 +26,7 @@ class AccountMappingWorkbookImporter
|
||||
'goods' => ['商品名称关键词', '商品名称', '商品描述', '货品名称'],
|
||||
'invoice' => ['票据口径', '票据', '票据类型', '发票'],
|
||||
'category' => ['财务类型', '大类', '业务性质', '科目大类', '分类'],
|
||||
'feeMemo' => ['费用备注', '费用说明', '费用备注说明'],
|
||||
'basis' => ['方向基准', '基准'],
|
||||
'dAcct' => ['借方科目', '借/贷(借)', '借/贷(借)', '借方'],
|
||||
'dSub' => ['借方二级'],
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
289
modules/EcommerceFinance/Services/FinanceLedgerBaseImporter.php
Normal file
289
modules/EcommerceFinance/Services/FinanceLedgerBaseImporter.php
Normal file
@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 导入财务台账的基础数据:账户、店铺、公司和期初余额。
|
||||
*
|
||||
* 源文件是财务维护的《银行当前余额-*.xlsx》。这里只做结构校验和全量
|
||||
* 覆盖,不推断业务口径;导入完成后由 FinanceLedgerCalculator 重算流水。
|
||||
*/
|
||||
class FinanceLedgerBaseImporter
|
||||
{
|
||||
public function import(string $path, ?string $openingDate = null): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException('基础数据文件不存在:' . $path);
|
||||
}
|
||||
|
||||
$reader = IOFactory::createReaderForFile($path);
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($path);
|
||||
|
||||
try {
|
||||
$accounts = $this->accounts($spreadsheet->getSheetByName('账户总表'));
|
||||
$shops = $this->shops($spreadsheet->getSheetByName('店铺台账'));
|
||||
$companies = $this->companies($spreadsheet->getSheetByName('公司台账'));
|
||||
$openingDate = $openingDate ?: (string) config('finance_ledger.opening_date', '2026-10-17');
|
||||
$opening = $this->opening($spreadsheet->getSheetByName('每日资金流水'), $openingDate);
|
||||
} finally {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
|
||||
if ($accounts === []) {
|
||||
throw new RuntimeException('账户总表没有可导入的数据。');
|
||||
}
|
||||
|
||||
$accountKeys = array_fill_keys(array_map(
|
||||
static fn (array $row): string => (string) $row['account_short'],
|
||||
$accounts,
|
||||
), true);
|
||||
|
||||
$missing = [];
|
||||
foreach ($opening as $row) {
|
||||
if (! isset($accountKeys[$row['account_short']])) {
|
||||
$missing[] = $row['account_short'];
|
||||
}
|
||||
}
|
||||
if ($missing !== []) {
|
||||
throw new RuntimeException('期初账户在账户总表中不存在:' . implode('、', array_slice(array_unique($missing), 0, 10)));
|
||||
}
|
||||
|
||||
$accountBalance = round(array_sum(array_column($accounts, 'current_balance')), 2);
|
||||
$openingBalance = round(array_sum(array_column($opening, 'opening_balance')), 2);
|
||||
if (abs($accountBalance - $openingBalance) > 0.01) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'期初合计 %.2f 与账户余额合计 %.2f 不一致。',
|
||||
$openingBalance,
|
||||
$accountBalance,
|
||||
));
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$connection = $this->connection();
|
||||
|
||||
$connection->transaction(function () use ($connection, $accounts, $shops, $companies, $opening, $now): void {
|
||||
$connection->table('fin_account_opening')->delete();
|
||||
$connection->table('fin_shops')->delete();
|
||||
$connection->table('fin_companies')->delete();
|
||||
$connection->table('fin_accounts')->delete();
|
||||
|
||||
$this->insert($connection, 'fin_accounts', $accounts, $now);
|
||||
$this->insert($connection, 'fin_shops', $shops, $now);
|
||||
$this->insert($connection, 'fin_companies', $companies, $now);
|
||||
$this->insert($connection, 'fin_account_opening', $opening, $now);
|
||||
});
|
||||
|
||||
return [
|
||||
'accounts' => count($accounts),
|
||||
'shops' => count($shops),
|
||||
'companies' => count($companies),
|
||||
'opening_rows' => count($opening),
|
||||
'opening_sum' => $openingBalance,
|
||||
'account_balance_sum' => $accountBalance,
|
||||
'opening_date' => $openingDate,
|
||||
];
|
||||
}
|
||||
|
||||
private function accounts(?Worksheet $sheet): array
|
||||
{
|
||||
if (! $sheet) {
|
||||
throw new RuntimeException('缺少「账户总表」工作表。');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($this->rows($sheet, 2, 23) as $row) {
|
||||
$company = $this->text($row[0] ?? null);
|
||||
$short = $this->text($row[1] ?? null);
|
||||
if ($company === '' || $short === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'company_name' => $company,
|
||||
'account_short' => $short,
|
||||
'region' => null,
|
||||
'account_no' => $this->text($row[2] ?? null),
|
||||
'bank_name' => $this->text($row[3] ?? null),
|
||||
'bank_city_branch' => $this->text($row[4] ?? null),
|
||||
'account_type' => $this->text($row[5] ?? null),
|
||||
'company_plan' => $this->text($row[6] ?? null),
|
||||
'account_last4' => $this->text($row[7] ?? null),
|
||||
'single_limit' => $this->text($row[8] ?? null),
|
||||
'daily_limit' => $this->text($row[9] ?? null),
|
||||
'monthly_limit' => $this->text($row[10] ?? null),
|
||||
'yearly_limit' => $this->text($row[11] ?? null),
|
||||
'current_balance' => $this->number($row[12] ?? null),
|
||||
'blocked_amount' => $this->number($row[13] ?? null),
|
||||
'daily_available' => $this->number($row[14] ?? null),
|
||||
'clearing_days' => $this->nullableNumber($row[15] ?? null),
|
||||
'raise_target' => $this->text($row[16] ?? null),
|
||||
'raise_status' => $this->text($row[17] ?? null),
|
||||
'raise_actions' => $this->text($row[18] ?? null),
|
||||
'done_actions' => $this->text($row[19] ?? null),
|
||||
'next_action' => $this->text($row[20] ?? null),
|
||||
'remark' => $this->text($row[21] ?? null),
|
||||
'owner' => $this->text($row[22] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function shops(?Worksheet $sheet): array
|
||||
{
|
||||
if (! $sheet) {
|
||||
throw new RuntimeException('缺少「店铺台账」工作表。');
|
||||
}
|
||||
|
||||
$statusMap = ['正常' => '在营', '关闭中' => '暂停', '已关闭' => '已注销'];
|
||||
$rows = [];
|
||||
foreach ($this->rows($sheet, 6, 8) as $row) {
|
||||
$name = $this->text($row[0] ?? null);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$status = $this->text($row[4] ?? null);
|
||||
$rows[] = [
|
||||
'shop_name' => $name,
|
||||
'platform' => $this->text($row[1] ?? null),
|
||||
'company_name' => $this->text($row[2] ?? null),
|
||||
'account_short' => $this->text($row[3] ?? null),
|
||||
'status' => $statusMap[$status] ?? $status,
|
||||
'payout_account' => $this->text($row[5] ?? null),
|
||||
'shop_balance' => $this->number($row[6] ?? null),
|
||||
'remark' => $this->text($row[7] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function companies(?Worksheet $sheet): array
|
||||
{
|
||||
if (! $sheet) {
|
||||
throw new RuntimeException('缺少「公司台账」工作表。');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($this->rows($sheet, 6, 10) as $row) {
|
||||
$name = $this->text($row[0] ?? null);
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'name' => $name,
|
||||
'register_place' => $this->text($row[1] ?? null),
|
||||
'business_type' => $this->text($row[2] ?? null),
|
||||
'tax_type' => $this->text($row[3] ?? null),
|
||||
'status' => $this->text($row[4] ?? null),
|
||||
'basic_bank' => $this->text($row[5] ?? null),
|
||||
'basic_account_last4' => $this->text($row[6] ?? null),
|
||||
'remark' => $this->text($row[9] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function opening(?Worksheet $sheet, string $openingDate): array
|
||||
{
|
||||
if (! $sheet) {
|
||||
throw new RuntimeException('缺少「每日资金流水」工作表。');
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($this->rows($sheet, 6, 8) as $row) {
|
||||
$short = $this->text($row[2] ?? null);
|
||||
if ($short === '') {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'account_short' => $short,
|
||||
'opening_date' => $openingDate,
|
||||
'opening_balance' => $this->number($row[3] ?? null),
|
||||
'remark' => $this->text($row[7] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return array<int,array<int,mixed>> */
|
||||
private function rows(Worksheet $sheet, int $startRow, int $columnCount): array
|
||||
{
|
||||
$lastRow = $sheet->getHighestDataRow();
|
||||
if ($lastRow < $startRow) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $sheet->rangeToArray(
|
||||
'A' . $startRow . ':' . \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($columnCount) . $lastRow,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
private function insert($connection, string $table, array $rows, $now): void
|
||||
{
|
||||
foreach ($rows as &$row) {
|
||||
$row['created_at'] = $now;
|
||||
$row['updated_at'] = $now;
|
||||
}
|
||||
unset($row);
|
||||
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
$connection->table($table)->insert($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
private function connection()
|
||||
{
|
||||
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
}
|
||||
|
||||
private function text(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
$text = trim((string) $value);
|
||||
if (in_array($text, ['#N/A', '#DIV/0!', '#VALUE!', '#REF!', 'None'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return preg_replace('/\s+/u', ' ', $text) ?? $text;
|
||||
}
|
||||
|
||||
private function number(mixed $value): float
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return 0.0;
|
||||
}
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return round((float) $value, 2);
|
||||
}
|
||||
$text = str_replace([',', ',', '¥', '¥', ' '], '', trim((string) $value));
|
||||
if ($text === '' || $text === '-' || $text === '/') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return is_numeric($text) ? round((float) $text, 2) : 0.0;
|
||||
}
|
||||
|
||||
private function nullableNumber(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '' || trim((string) $value) === '-') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->number($value);
|
||||
}
|
||||
}
|
||||
182
modules/EcommerceFinance/Services/FinanceLedgerCalculator.php
Normal file
182
modules/EcommerceFinance/Services/FinanceLedgerCalculator.php
Normal file
@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 按账户滚动计算每日资金流水,并回写账户当前余额、每日可出金额和店铺余额。
|
||||
*
|
||||
* 计算口径固定在服务端,页面只读结果:
|
||||
* 期末 = 期初 + 当日入账 - 当日出账
|
||||
* 账户当前余额 = 最新一条流水期末
|
||||
* 每日可出金额 = 当前余额 - 卡住金额
|
||||
* 店铺余额 = 店铺所属公司名下账户当前余额合计(待财务确认)
|
||||
*/
|
||||
class FinanceLedgerCalculator
|
||||
{
|
||||
public function recalculate(): array
|
||||
{
|
||||
$connection = $this->connection();
|
||||
$openingDate = (string) config('finance_ledger.opening_date', '2026-10-17');
|
||||
$now = now();
|
||||
|
||||
$opening = $connection->table('fin_account_opening')
|
||||
->where('opening_date', '<=', $openingDate)
|
||||
->orderBy('opening_date')
|
||||
->get()
|
||||
->groupBy('account_short')
|
||||
->map(static fn ($rows) => $rows->last());
|
||||
|
||||
$accountCompanies = $connection->table('fin_accounts')
|
||||
->select(['account_short', 'company_name'])
|
||||
->get()
|
||||
->groupBy('account_short')
|
||||
->map(static fn ($rows) => (string) ($rows->first()->company_name ?? ''));
|
||||
|
||||
$mapping = $connection->table('fin_account_map')
|
||||
->whereNotNull('account_short')
|
||||
->where('account_short', '<>', '')
|
||||
->get(['source', 'source_value', 'account_short'])
|
||||
->keyBy(static fn ($row): string => $row->source . "\0" . $row->source_value);
|
||||
|
||||
$transactions = $connection->table('fin_transactions')
|
||||
->where('txn_date', '>=', $openingDate)
|
||||
->orderBy('txn_date')
|
||||
->orderBy('id')
|
||||
->get(['txn_date', 'account_short', 'source', 'source_value', 'income', 'expense']);
|
||||
|
||||
$daily = [];
|
||||
$balances = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$mapKey = $transaction->source . "\0" . $transaction->source_value;
|
||||
$account = (string) ($mapping->get($mapKey)->account_short ?? '');
|
||||
if ($account === '') {
|
||||
$account = trim((string) $transaction->account_short);
|
||||
}
|
||||
if ($account === '' || ! $opening->has($account)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = (string) $transaction->txn_date;
|
||||
$key = $account . "\0" . $date;
|
||||
$daily[$key] ??= [
|
||||
'account_short' => $account,
|
||||
'flow_date' => $date,
|
||||
'inflow' => 0.0,
|
||||
'outflow' => 0.0,
|
||||
];
|
||||
$daily[$key]['inflow'] += (float) $transaction->income;
|
||||
$daily[$key]['outflow'] += (float) $transaction->expense;
|
||||
}
|
||||
|
||||
$flows = [];
|
||||
$latest = [];
|
||||
|
||||
foreach ($opening as $account => $openingRow) {
|
||||
$account = (string) $account;
|
||||
$balance = (float) $openingRow->opening_balance;
|
||||
$company = $accountCompanies->get($account, '');
|
||||
$start = max((string) $openingRow->opening_date, $openingDate);
|
||||
|
||||
$rows = array_values(array_filter(
|
||||
$daily,
|
||||
static fn (array $row): bool => $row['account_short'] === $account,
|
||||
));
|
||||
usort($rows, static fn (array $a, array $b): int => strcmp($a['flow_date'], $b['flow_date']));
|
||||
|
||||
$openingFlow = [
|
||||
'flow_date' => $start,
|
||||
'company_name' => $company,
|
||||
'account_short' => $account,
|
||||
'opening_balance' => round($balance, 2),
|
||||
'inflow' => 0.0,
|
||||
'outflow' => 0.0,
|
||||
'closing_balance' => round($balance, 2),
|
||||
'remark' => '期初',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$flows[] = $openingFlow;
|
||||
$openingIndex = array_key_last($flows);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ($row['flow_date'] < $start) {
|
||||
continue;
|
||||
}
|
||||
$inflow = round((float) $row['inflow'], 2);
|
||||
$outflow = round((float) $row['outflow'], 2);
|
||||
if (abs($inflow) < 0.005 && abs($outflow) < 0.005) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($row['flow_date'] === $start) {
|
||||
// 期初日发生动账时合并到同一行,避免唯一键冲突。
|
||||
$balance = round($balance + $inflow - $outflow, 2);
|
||||
$flows[$openingIndex]['inflow'] = $inflow;
|
||||
$flows[$openingIndex]['outflow'] = $outflow;
|
||||
$flows[$openingIndex]['closing_balance'] = $balance;
|
||||
$flows[$openingIndex]['remark'] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
$balance = round($balance + $inflow - $outflow, 2);
|
||||
$flows[] = [
|
||||
'flow_date' => $row['flow_date'],
|
||||
'company_name' => $company,
|
||||
'account_short' => $account,
|
||||
'opening_balance' => round($balance - $inflow + $outflow, 2),
|
||||
'inflow' => $inflow,
|
||||
'outflow' => $outflow,
|
||||
'closing_balance' => $balance,
|
||||
'remark' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$latest[$account] = $balance;
|
||||
}
|
||||
|
||||
$connection->transaction(function () use ($connection, $flows, $latest, $now): void {
|
||||
$connection->table('fin_daily_flows')->delete();
|
||||
foreach (array_chunk($flows, 500) as $chunk) {
|
||||
$connection->table('fin_daily_flows')->insert($chunk);
|
||||
}
|
||||
|
||||
foreach ($latest as $account => $balance) {
|
||||
$connection->table('fin_accounts')
|
||||
->where('account_short', $account)
|
||||
->update([
|
||||
'current_balance' => round((float) $balance, 2),
|
||||
'daily_available' => $connection->raw('ROUND(current_balance - blocked_amount, 2)'),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$companies = $connection->table('fin_accounts')
|
||||
->selectRaw('company_name, SUM(current_balance) AS balance')
|
||||
->groupBy('company_name')
|
||||
->pluck('balance', 'company_name');
|
||||
|
||||
foreach ($companies as $company => $balance) {
|
||||
$connection->table('fin_shops')
|
||||
->where('company_name', (string) $company)
|
||||
->update(['shop_balance' => round((float) $balance, 2), 'updated_at' => $now]);
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
'accounts' => count($latest),
|
||||
'flows' => count($flows),
|
||||
'opening_date' => $openingDate,
|
||||
'balance_sum' => round(array_sum($latest), 2),
|
||||
];
|
||||
}
|
||||
|
||||
private function connection()
|
||||
{
|
||||
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,675 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 从 AI 库 JSReportReplica.rpt 读取财务台账动账明细。
|
||||
*
|
||||
* 数据口径以《财务台账模块开发规格.md》第 3 章为准;所有 SQL 都是固定、
|
||||
* 参数化的 SELECT,不读取 base,也不写上游。同步结果交给
|
||||
* FinanceLedgerTransactionWriter 全量替换并重算流水。
|
||||
*/
|
||||
class FinanceLedgerRptSynchronizer
|
||||
{
|
||||
private const SOURCE = 'operations_replica';
|
||||
|
||||
private const DING_SINCE = '2025-10-18';
|
||||
|
||||
private const JST_WORK_ORDER_TYPE = '9E5A531F48D94AF4B8611FA6918ADA29';
|
||||
|
||||
private const FIXED_ASSET_PROCESS = 'PROC-6407E98B-040A-4C96-AA7D-696650FC1A25';
|
||||
|
||||
private const DETAIL_FIELDS = [
|
||||
'PROC-B290696F-EC2A-4F76-87BA-E0A12E10D09F' => '收款费用明细',
|
||||
'PROC-C5159B8D-F412-4DF8-981D-64EBB8D03EB6' => '收款费用明细',
|
||||
'PROC-39DD70DE-DAB2-4A5F-B8F1-0B0B68BD0C88' => '应付明细',
|
||||
'PROC-BBAC7187-9E59-4942-941A-59C7EAF0CEDA' => '收款费用明细',
|
||||
'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF' => '收款费用明细',
|
||||
'PROC-6407E98B-040A-4C96-AA7D-696650FC1A25' => '固定资产报废或出售出售明细',
|
||||
];
|
||||
|
||||
private const CHANNEL_NAMES = [
|
||||
'alipayCSV' => '支付宝',
|
||||
'JDWallet' => '京东钱包',
|
||||
'PinduoduoImport' => '拼多多',
|
||||
'TouTiaoFXGZJ' => '抖音',
|
||||
'WeChatPay' => '微信',
|
||||
'CommonBillTGCPay' => '腾讯广告',
|
||||
'PddMarketingImport' => '拼多多营销',
|
||||
'XiaohsSeller' => '小红书',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly UpstreamReadOnlyQueryFactory $queries,
|
||||
private readonly FinanceLedgerTransactionWriter $writer,
|
||||
) {
|
||||
}
|
||||
|
||||
public function sync(bool $recalculate = true, ?string $from = null, ?string $to = null): array
|
||||
{
|
||||
$from = trim((string) ($from ?? config('finance_ledger.window_start', '2026-04-18')));
|
||||
$to = trim((string) ($to ?? config('finance_ledger.window_end', ''))) ?: now()->toDateString();
|
||||
|
||||
$transactions = array_merge(
|
||||
$this->dingTalkRows(),
|
||||
$this->workOrderRows(),
|
||||
$this->payableRows($from),
|
||||
$this->storeOpeningRows($from),
|
||||
$this->withdrawalRows($from),
|
||||
);
|
||||
|
||||
$transactions = array_values(array_filter(
|
||||
$transactions,
|
||||
static fn (array $row): bool => $row['txn_date'] >= $from && $row['txn_date'] <= $to,
|
||||
));
|
||||
|
||||
if ($transactions === []) {
|
||||
throw new RuntimeException('rpt 没有返回窗口内的动账明细。');
|
||||
}
|
||||
|
||||
$result = $this->writer->replace($transactions, $recalculate);
|
||||
$result['source_counts'] = array_count_values(array_column($transactions, 'process_type'));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function dingTalkRows(): array
|
||||
{
|
||||
$processCodes = array_keys((array) config('finance_ledger.dingtalk_process_codes', []));
|
||||
if ($processCodes === []) {
|
||||
throw new RuntimeException('未配置钉钉审批流程编码。');
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($processCodes), '?'));
|
||||
$query = $this->queries->forSource(self::SOURCE);
|
||||
|
||||
$workflows = $query->select(
|
||||
'SELECT processInstanceId, processCode, businessId, title, status, originatorUserId, '
|
||||
. 'originatorDeptName, joinAt, finishTime FROM rpt.ding_Workflow '
|
||||
. 'WHERE joinAt >= ? AND processCode IN (' . $placeholders . ')',
|
||||
[self::DING_SINCE, ...$processCodes],
|
||||
);
|
||||
|
||||
if ($workflows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$schemes = $query->select(
|
||||
'SELECT s.id, s.processInstanceId, s.name, CAST(s.value AS VARCHAR(MAX)) AS value '
|
||||
. 'FROM rpt.ding_WorkflowScheme s JOIN rpt.ding_Workflow w ON w.processInstanceId = s.processInstanceId '
|
||||
. 'WHERE w.joinAt >= ? AND w.processCode IN (' . $placeholders . ') '
|
||||
. "AND s.name IN ('付款明细','收款费用明细','应付明细','固定资产报废或出售明细','收款合计','付款金额合计') "
|
||||
. 'ORDER BY s.id',
|
||||
[self::DING_SINCE, ...$processCodes],
|
||||
);
|
||||
|
||||
$employees = [];
|
||||
foreach ($query->select('SELECT userid, name FROM rpt.hr_employee') as $employee) {
|
||||
$employees[(string) $employee['userid']] = trim((string) ($employee['name'] ?? ''));
|
||||
}
|
||||
|
||||
$byInstance = [];
|
||||
foreach ($schemes as $scheme) {
|
||||
$pid = (string) $scheme['processInstanceId'];
|
||||
$name = (string) $scheme['name'];
|
||||
$byInstance[$pid][$name] = (string) $scheme['value'];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($workflows as $workflow) {
|
||||
$pid = (string) $workflow['processInstanceId'];
|
||||
$code = (string) $workflow['processCode'];
|
||||
$processName = (string) (config('finance_ledger.dingtalk_process_codes.' . $code) ?: '钉钉审批');
|
||||
if ((string) $workflow['status'] !== 'COMPLETED') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$applicant = $employees[(string) ($workflow['originatorUserId'] ?? '')] ?? '';
|
||||
if ($applicant === '') {
|
||||
if (preg_match('/^(?:\d{2}-\d{2}\+?)?(.+?)提交的/u', (string) ($workflow['title'] ?? ''), $matches) === 1) {
|
||||
$applicant = trim($matches[1]);
|
||||
}
|
||||
}
|
||||
$dept = trim((string) ($workflow['originatorDeptName'] ?? ''));
|
||||
$fields = $byInstance[$pid] ?? [];
|
||||
$approval = trim((string) ($workflow['businessId'] ?? ''));
|
||||
|
||||
if ($code === self::FIXED_ASSET_PROCESS) {
|
||||
foreach ($this->detailRows($fields['固定资产报废或出售明细'] ?? '', $code) as $detail) {
|
||||
$text = $this->joinParts([
|
||||
$applicant,
|
||||
$dept,
|
||||
$detail['use'],
|
||||
$detail['asset_name'] !== '' ? '资产:' . $detail['asset_name'] : '',
|
||||
]);
|
||||
$rows[] = $this->row([
|
||||
'date' => $detail['pay_time'],
|
||||
'account_short' => '',
|
||||
'source_value' => '银行存款_个人账户_招商银行-朱俊祥',
|
||||
'source' => 'dingtalk',
|
||||
'process_type' => $processName,
|
||||
'approval_no' => $approval,
|
||||
'detail' => $text,
|
||||
'income' => $detail['sale_price'],
|
||||
'expense' => 0,
|
||||
'pid' => $pid,
|
||||
'seq' => $detail['seq'],
|
||||
'acct_code' => '1002.02.046',
|
||||
'acct_name' => '银行存款_个人账户_招商银行-朱俊祥',
|
||||
]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$payments = $this->paymentRows($fields['付款明细'] ?? '');
|
||||
$details = $this->detailRows($fields[self::DETAIL_FIELDS[$code] ?? ''] ?? '', $code);
|
||||
$detailMap = [];
|
||||
foreach ($details as $detail) {
|
||||
foreach ($this->seqTokens($detail['seq']) ?: [''] as $token) {
|
||||
$detailMap[$token][] = $detail;
|
||||
}
|
||||
}
|
||||
if (count($details) === 1 && $detailMap === []) {
|
||||
$detailMap['_all'] = $details;
|
||||
}
|
||||
|
||||
foreach ($payments as $payment) {
|
||||
if ($payment['amount'] == 0.0 && $payment['code'] === '') {
|
||||
continue;
|
||||
}
|
||||
$tokens = $this->seqTokens($payment['seq']) ?: ['_all'];
|
||||
$matched = [];
|
||||
foreach ($tokens as $token) {
|
||||
$matched = array_merge($matched, $detailMap[$token] ?? []);
|
||||
}
|
||||
if ($matched === [] && isset($detailMap['_all'])) {
|
||||
$matched = $detailMap['_all'];
|
||||
}
|
||||
if ($matched === [] && count($details) === 1) {
|
||||
$matched = $details;
|
||||
}
|
||||
|
||||
$uses = $payees = $suppliers = $shops = $items = [];
|
||||
foreach ($matched as $detail) {
|
||||
if ($detail['use'] !== '') {
|
||||
$uses[] = $detail['use'];
|
||||
}
|
||||
if ($detail['payee'] !== '') {
|
||||
$payees[] = $detail['payee'];
|
||||
}
|
||||
if ($detail['supplier'] !== '') {
|
||||
$suppliers[] = $detail['supplier'];
|
||||
}
|
||||
if ($detail['shop'] !== '') {
|
||||
$shops[] = $detail['shop'];
|
||||
}
|
||||
if ($detail['item'] !== '') {
|
||||
$items[] = $detail['item'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($code === 'PROC-39DD70DE-DAB2-4A5F-B8F1-0B0B68BD0C88') {
|
||||
$express = $projects = [];
|
||||
foreach ($matched as $detail) {
|
||||
if ($detail['express'] !== '') {
|
||||
$express[] = $detail['express'];
|
||||
}
|
||||
if ($detail['project'] !== '') {
|
||||
$projects[] = $detail['project'];
|
||||
}
|
||||
}
|
||||
$payeeText = $this->unique($payees) ?: ($this->unique($projects) ?: $this->unique($express));
|
||||
$supplierText = $this->unique($suppliers) ?: ($this->unique($express) ?: $this->unique($projects));
|
||||
$text = $this->joinParts([$applicant, $dept, $payeeText, $supplierText]);
|
||||
} else {
|
||||
$extra = '';
|
||||
if ($uses === [] && ($shops !== [] || $items !== [])) {
|
||||
$extra = $this->unique($shops) . ($items !== [] ? '/' . $this->unique($items) : '');
|
||||
$diff = array_sum(array_map(static fn (array $detail): float => (float) $detail['diff'], $matched));
|
||||
if ($diff != 0.0) {
|
||||
$extra .= ';差价' . number_format($diff, 2, '.', '');
|
||||
}
|
||||
}
|
||||
$text = $this->joinParts([$applicant, $dept, $this->unique($uses) ?: $extra]);
|
||||
}
|
||||
|
||||
$rows[] = $this->row([
|
||||
'date' => $payment['date'],
|
||||
'account_short' => '',
|
||||
'source_value' => $payment['name'],
|
||||
'source' => 'dingtalk',
|
||||
'process_type' => $processName,
|
||||
'approval_no' => $approval,
|
||||
'detail' => $text,
|
||||
'income' => 0,
|
||||
'expense' => $payment['amount'],
|
||||
'pid' => $pid,
|
||||
'seq' => $payment['seq'],
|
||||
'acct_code' => $payment['code'],
|
||||
'acct_name' => $payment['name'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function workOrderRows(): array
|
||||
{
|
||||
$rows = $this->queries->forSource(self::SOURCE)->select(
|
||||
'SELECT Id, woNo, createby, created, completeTime, businessFieldList FROM rpt.jst_workOrderInfo '
|
||||
. 'WHERE woTypeId = ? AND status = ?',
|
||||
[self::JST_WORK_ORDER_TYPE, 'CONFIRMED'],
|
||||
);
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$fields = json_decode((string) ($row['businessFieldList'] ?? ''), true);
|
||||
if (! is_array($fields)) {
|
||||
continue;
|
||||
}
|
||||
$map = [];
|
||||
foreach ($fields as $field) {
|
||||
$id = (string) ($field['fieldId'] ?? '');
|
||||
$map[substr($id, -6)] = $field['value'] ?? null;
|
||||
}
|
||||
$amount = $this->toNumber($map['091551'] ?? null);
|
||||
$kind = trim((string) ($map['101125'] ?? ''));
|
||||
$state = trim((string) ($map['101747'] ?? ''));
|
||||
if ($amount <= 0 || $kind !== '入账' || ! in_array($state, ['已到账', '已收到'], true)) {
|
||||
continue;
|
||||
}
|
||||
$result[] = $this->row([
|
||||
'date' => $this->date($map['101432'] ?? null),
|
||||
'account_short' => trim((string) ($map['101730'] ?? '')),
|
||||
'source_value' => trim((string) ($map['101730'] ?? '')),
|
||||
'source' => 'workorder',
|
||||
'process_type' => '聚工单查账流程',
|
||||
'approval_no' => trim((string) ($row['woNo'] ?? '')),
|
||||
'detail' => $this->joinParts([trim((string) ($row['createby'] ?? '')), '查账工单', $state]),
|
||||
'income' => $amount,
|
||||
'expense' => 0,
|
||||
'pid' => 'JST-' . (string) ($row['Id'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function payableRows(string $from): array
|
||||
{
|
||||
$sql = 'WITH paid AS ('
|
||||
. 'SELECT r.DetailId, SUM(r.amountLower) AS paid_amount FROM rpt.biz_bank_receipt_detail r '
|
||||
. "WHERE r.DetailId IS NOT NULL AND r.BindStatus = N'已绑定' AND r.MatchStatus = N'匹配成功' AND r.amountLower > 0 "
|
||||
. 'GROUP BY r.DetailId'
|
||||
. '), detail AS ('
|
||||
. 'SELECT d.id, d.bill_period, d.business_no, d.third_short_name, d.our_subsidiary_name, '
|
||||
. 'd.third_subsidiary_name, d.deduct_amount, ISNULL(p.paid_amount,0) AS paid_amount, '
|
||||
. "CASE WHEN ISNULL(p.paid_amount,0) <= 0 THEN N'等待打款' "
|
||||
. "WHEN ISNULL(p.paid_amount,0) >= d.deduct_amount THEN N'打款完成' ELSE N'打款中' END AS receipt_pay_status "
|
||||
. 'FROM rpt.biz_quota_deduct_detail d LEFT JOIN paid p ON p.DetailId = d.id WHERE d.is_del = 0'
|
||||
. ') SELECT d.id, d.bill_period, d.business_no, d.third_short_name, d.our_subsidiary_name, '
|
||||
. 'd.third_subsidiary_name, d.deduct_amount, d.paid_amount, d.receipt_pay_status, '
|
||||
. 'r.Id, r.payerAccountName, r.UploadTime, r.amountLower, r.transactionDate, r.bookingDate '
|
||||
. 'FROM detail d JOIN rpt.biz_bank_receipt_detail r ON r.DetailId = d.id AND r.TaskId = d.business_no '
|
||||
. "WHERE d.receipt_pay_status IN (N'打款中',N'打款完成')";
|
||||
|
||||
$rows = $this->queries->forSource(self::SOURCE)->select($sql);
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$amount = $this->toNumber($row['amountLower'] ?? null);
|
||||
if ($amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
$date = $this->date($row['transactionDate'] ?? null)
|
||||
?: $this->date($row['bookingDate'] ?? null)
|
||||
?: $this->date($row['UploadTime'] ?? null);
|
||||
$result[] = $this->row([
|
||||
'date' => $date,
|
||||
'account_short' => trim((string) ($row['payerAccountName'] ?? '')),
|
||||
'source_value' => trim((string) ($row['payerAccountName'] ?? '')),
|
||||
'source' => 'payable',
|
||||
'process_type' => '供应商应付',
|
||||
'approval_no' => trim((string) ($row['business_no'] ?? '')),
|
||||
'detail' => $this->joinParts([
|
||||
trim((string) ($row['third_short_name'] ?? '')),
|
||||
trim((string) ($row['our_subsidiary_name'] ?? '')),
|
||||
trim((string) ($row['third_subsidiary_name'] ?? '')),
|
||||
trim((string) ($row['bill_period'] ?? '')),
|
||||
]),
|
||||
'income' => 0,
|
||||
'expense' => $amount,
|
||||
'pid' => 'PAY-' . (string) ($row['id'] ?? '') . '-' . (string) ($row['Id'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function storeOpeningRows(string $from): array
|
||||
{
|
||||
$sql = 'SELECT a.flowId, a.AccountCode, a.AccountName, a.costType, a.money, a.paymentTime, '
|
||||
. 'a.paymentAccount, b.erpShopName, b.platform, b.applyUser, b.applyDepartment '
|
||||
. 'FROM rpt.fys_shopInfo_kingdeeCode_flow a '
|
||||
. 'LEFT JOIN rpt.fys_shopInfo_flow b ON a.flowId = b.flowId '
|
||||
. 'WHERE a.flowId IN (SELECT flowId FROM rpt.flow_flow WHERE flowType = 8 AND flowStatus = 3) '
|
||||
. 'AND a.type = 1 AND a.paymentTime >= ? AND a.isPaymentMade = 1 AND a.money > 0';
|
||||
|
||||
$rows = $this->queries->forSource(self::SOURCE)->select($sql, [$from]);
|
||||
$seen = [];
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$key = implode('|', [
|
||||
(string) ($row['flowId'] ?? ''),
|
||||
(string) ($row['costType'] ?? ''),
|
||||
(string) ($row['money'] ?? ''),
|
||||
(string) ($row['paymentTime'] ?? ''),
|
||||
trim((string) ($row['paymentAccount'] ?? '')),
|
||||
]);
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
|
||||
$amount = $this->toNumber($row['money'] ?? null);
|
||||
$note = '';
|
||||
if ($amount > 10_000_000) {
|
||||
$note = '原值异常已按申请单修正为 200.00';
|
||||
$amount = 200.0;
|
||||
}
|
||||
$account = trim((string) ($row['paymentAccount'] ?? ''));
|
||||
if (in_array($account, ['无', '-'], true)) {
|
||||
$account = '';
|
||||
}
|
||||
$result[] = $this->row([
|
||||
'date' => $this->date($row['paymentTime'] ?? null),
|
||||
'account_short' => $account,
|
||||
'source_value' => $account,
|
||||
'source' => 'store',
|
||||
'process_type' => '店铺开店费用',
|
||||
'approval_no' => trim((string) ($row['flowId'] ?? '')),
|
||||
'detail' => $this->joinParts([
|
||||
trim((string) ($row['applyUser'] ?? '')),
|
||||
trim((string) ($row['applyDepartment'] ?? '')),
|
||||
trim((string) ($row['erpShopName'] ?? '')),
|
||||
trim((string) ($row['platform'] ?? '')),
|
||||
trim((string) ($row['costType'] ?? '')),
|
||||
]),
|
||||
'income' => 0,
|
||||
'expense' => $amount,
|
||||
'pid' => 'SHOP-' . (string) ($row['flowId'] ?? '') . '-' . (string) ($row['costType'] ?? '') . '-' . (string) ($row['money'] ?? ''),
|
||||
'note' => $note,
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function withdrawalRows(string $from): array
|
||||
{
|
||||
$sql = 'WITH d AS ('
|
||||
. 'SELECT a.Id, a.shop_id, a.channel, a.fee_time, a.fee_name, a.inout_type, a.amount, a.primary_key, '
|
||||
. 'ROW_NUMBER() OVER (PARTITION BY a.primary_key ORDER BY a.Id) AS rn '
|
||||
. "FROM rpt.jst_originalBillInfo a WHERE a.fee_name LIKE N'%提现%' AND a.fee_time >= ?"
|
||||
. ') SELECT d.Id, d.channel, d.fee_time, d.fee_name, d.amount, b.shop_name, d.inout_type '
|
||||
. 'FROM d LEFT JOIN rpt.jst_shopInfo b ON d.shop_id = b.shop_id WHERE d.rn = 1';
|
||||
|
||||
$rows = $this->queries->forSource(self::SOURCE)->select($sql, [$from]);
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
if ((string) ($row['inout_type'] ?? '') !== '-1') {
|
||||
continue;
|
||||
}
|
||||
$shop = trim((string) ($row['shop_name'] ?? ''));
|
||||
$channel = self::CHANNEL_NAMES[(string) ($row['channel'] ?? '')] ?? (string) ($row['channel'] ?? '');
|
||||
$result[] = $this->row([
|
||||
'date' => $this->date($row['fee_time'] ?? null),
|
||||
'account_short' => $shop,
|
||||
'source_value' => $shop,
|
||||
'source' => 'shop',
|
||||
'process_type' => '店铺提现',
|
||||
'approval_no' => (string) ($row['Id'] ?? ''),
|
||||
'detail' => $this->joinParts([$shop, $channel, trim((string) ($row['fee_name'] ?? ''))]),
|
||||
'income' => $this->toNumber($row['amount'] ?? null),
|
||||
'expense' => 0,
|
||||
'pid' => 'BILL-' . (string) ($row['Id'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function row(array $data): array
|
||||
{
|
||||
return [
|
||||
'txn_date' => $data['date'] ?? '',
|
||||
'account_short' => (string) ($data['account_short'] ?? ''),
|
||||
'source' => (string) ($data['source'] ?? ''),
|
||||
'source_value' => (string) ($data['source_value'] ?? ''),
|
||||
'process_type' => (string) ($data['process_type'] ?? ''),
|
||||
'approval_no' => (string) ($data['approval_no'] ?? ''),
|
||||
'detail' => (string) ($data['detail'] ?? ''),
|
||||
'income' => round((float) ($data['income'] ?? 0), 2),
|
||||
'expense' => round((float) ($data['expense'] ?? 0), 2),
|
||||
'pid' => (string) ($data['pid'] ?? ''),
|
||||
'seq' => (string) ($data['seq'] ?? ''),
|
||||
'acct_code' => (string) ($data['acct_code'] ?? ''),
|
||||
'acct_name' => (string) ($data['acct_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function paymentRows(?string $value): array
|
||||
{
|
||||
$rows = $this->parseRows($value);
|
||||
if ($rows !== null) {
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$result[] = [
|
||||
'seq' => trim((string) ($row['对应序号'] ?? '')),
|
||||
'amount' => $this->toNumber($row['付款金额'] ?? $row['付款总金额'] ?? null),
|
||||
'code' => trim((string) ($row['科目编码'] ?? '')),
|
||||
'name' => trim((string) ($row['科目名称'] ?? '')),
|
||||
'date' => $this->date($row['打款时间'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($this->corruptRows((string) $value) as $row) {
|
||||
$values = $row['values'];
|
||||
if ($values === []) {
|
||||
continue;
|
||||
}
|
||||
$code = '';
|
||||
$date = '';
|
||||
foreach ($values as $candidate) {
|
||||
if ($code === '' && preg_match('/^(1001|1002|1012)(\.\d{2,3})+$/', $candidate) === 1) {
|
||||
$code = $candidate;
|
||||
}
|
||||
if ($date === '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $candidate) === 1) {
|
||||
$date = substr($candidate, 0, 10);
|
||||
}
|
||||
}
|
||||
$result[] = [
|
||||
'seq' => trim((string) ($values[0] ?? '')),
|
||||
'amount' => $this->toNumber($values[2] ?? null),
|
||||
'code' => $code,
|
||||
'name' => trim((string) ($values[4] ?? '')),
|
||||
'date' => $date,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
private function detailRows(?string $value, string $processCode): array
|
||||
{
|
||||
$rows = $this->parseRows($value);
|
||||
if ($rows !== null) {
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$result[] = [
|
||||
'seq' => trim((string) ($row['序号'] ?? '')),
|
||||
'use' => trim((string) ($row['用途'] ?? '')),
|
||||
'payee' => trim((string) ($row['收款人全称'] ?? '')),
|
||||
'supplier' => trim((string) ($row['供应商'] ?? '')),
|
||||
'shop' => trim((string) ($row['店铺名称'] ?? '')),
|
||||
'item' => trim((string) ($row['商品ID'] ?? '')),
|
||||
'diff' => $this->toNumber($row['差价'] ?? null),
|
||||
'asset_name' => trim((string) ($row['固定资产名称'] ?? '')),
|
||||
'express' => trim((string) ($row['快递'] ?? '')),
|
||||
'project' => trim((string) ($row['核算项目'] ?? '')),
|
||||
'sale_price' => $this->toNumber($row['出售价格'] ?? null),
|
||||
'pay_time' => $this->date($row['付款时间'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($this->corruptRows((string) $value) as $row) {
|
||||
$fields = $row['fields'];
|
||||
$result[] = [
|
||||
'seq' => trim((string) ($fields['序号'] ?? ($row['values'][0] ?? ''))),
|
||||
'use' => trim((string) ($fields['用途'] ?? '')),
|
||||
'payee' => trim((string) ($fields['收款人全称'] ?? '')),
|
||||
'supplier' => trim((string) ($fields['供应商'] ?? '')),
|
||||
'shop' => trim((string) ($fields['店铺名称'] ?? '')),
|
||||
'item' => trim((string) ($fields['商品ID'] ?? '')),
|
||||
'diff' => $this->toNumber($fields['差价'] ?? null),
|
||||
'asset_name' => trim((string) ($fields['固定资产名称'] ?? '')),
|
||||
'express' => trim((string) ($fields['快递'] ?? '')),
|
||||
'project' => trim((string) ($fields['核算项目'] ?? '')),
|
||||
'sale_price' => $this->toNumber($fields['出售价格'] ?? null),
|
||||
'pay_time' => $this->date($fields['付款时间'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function parseRows(?string $value): ?array
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
if ($text === '' || $text === '[]') {
|
||||
return [];
|
||||
}
|
||||
$data = json_decode($text, true);
|
||||
if (! is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($data as $object) {
|
||||
$row = [];
|
||||
foreach ((array) ($object['rowValue'] ?? []) as $cell) {
|
||||
$label = trim((string) ($cell['label'] ?? ''));
|
||||
$val = $cell['value'] ?? null;
|
||||
if (($val === null || $val === '') && is_array($cell['extendValue'] ?? null)) {
|
||||
$val = $cell['extendValue']['label'] ?? null;
|
||||
}
|
||||
$row[$label] = $val;
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return array<int,array{fields:array<string,string>,values:array<int,string>}> */
|
||||
private function corruptRows(string $value): array
|
||||
{
|
||||
if (trim($value) === '' || trim($value) === '[]') {
|
||||
return [];
|
||||
}
|
||||
$chunks = explode('{"rowValue":', $value);
|
||||
array_shift($chunks);
|
||||
$rows = [];
|
||||
foreach ($chunks as $chunk) {
|
||||
$fields = [];
|
||||
if (preg_match_all('/"label":"([^"]{1,40})"/u', $chunk, $labels, PREG_OFFSET_CAPTURE) !== false) {
|
||||
foreach ($labels[1] as $label) {
|
||||
$tail = substr($chunk, $label[1] + strlen($label[0]) + 2, 400);
|
||||
if (preg_match('/"value":"([^",}]*)/u', $tail, $match) === 1) {
|
||||
$fields[$label[0]] = $match[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
preg_match_all('/"value":"([^",}]*)/u', $chunk, $values);
|
||||
$flat = $values[1] ?? [];
|
||||
if ($flat !== []) {
|
||||
$fields['序号'] ??= $flat[0];
|
||||
}
|
||||
$rows[] = ['fields' => $fields, 'values' => $flat];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function date(mixed $value): string
|
||||
{
|
||||
$text = trim((string) ($value ?? ''));
|
||||
if (preg_match('/^(\d{4})[-\/年](\d{1,2})[-\/月](\d{1,2})/u', $text, $matches) !== 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $matches[1], $matches[2], $matches[3]);
|
||||
}
|
||||
|
||||
private function toNumber(mixed $value): float
|
||||
{
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return round((float) $value, 2);
|
||||
}
|
||||
$text = str_replace([',', ',', '¥', '¥', ' '], '', trim((string) ($value ?? '')));
|
||||
if ($text === '' || $text === '-' || $text === '/') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return is_numeric($text) ? round((float) $text, 2) : 0.0;
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
private function seqTokens(string $seq): array
|
||||
{
|
||||
return array_values(array_filter(preg_split('/[-,,、\s]+/u', $seq) ?: []));
|
||||
}
|
||||
|
||||
private function unique(array $values): string
|
||||
{
|
||||
$unique = [];
|
||||
foreach ($values as $value) {
|
||||
$value = trim((string) $value);
|
||||
if ($value !== '' && ! in_array($value, $unique, true)) {
|
||||
$unique[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return implode('、', $unique);
|
||||
}
|
||||
|
||||
private function joinParts(array $parts): string
|
||||
{
|
||||
$cleaned = [];
|
||||
foreach ($parts as $part) {
|
||||
$part = trim((string) $part);
|
||||
if ($part !== '' && ($cleaned === [] || end($cleaned) !== $part)) {
|
||||
$cleaned[] = $part;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(';', $cleaned);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 将已核对的财务台账快照(ledger_raw.json)导入 fin_transactions。
|
||||
*
|
||||
* 该入口用于本地开发和生产首次建账;日常刷新由 rpt 同步命令负责。
|
||||
* 快照本身不进入 Git,导入前必须确认文件摘要和行数。
|
||||
*/
|
||||
class FinanceLedgerSnapshotImporter
|
||||
{
|
||||
private const SOURCES = [
|
||||
'日常费用报销流程' => 'dingtalk',
|
||||
'种菜报销流程' => 'dingtalk',
|
||||
'应付申请流程' => 'dingtalk',
|
||||
'快递面单应付流程' => 'dingtalk',
|
||||
'线上代发业务报销' => 'dingtalk',
|
||||
'固定资产报废或出售流程' => 'dingtalk',
|
||||
'聚工单查账流程' => 'workorder',
|
||||
'供应商应付' => 'payable',
|
||||
'店铺开店费用' => 'store',
|
||||
'店铺提现' => 'shop',
|
||||
];
|
||||
|
||||
public function import(string $path, bool $recalculate = true): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException('台账快照文件不存在:' . $path);
|
||||
}
|
||||
|
||||
$payload = json_decode((string) file_get_contents($path), true);
|
||||
if (! is_array($payload) || $payload === []) {
|
||||
throw new RuntimeException('台账快照不是有效的 JSON 数组。');
|
||||
}
|
||||
|
||||
$connection = $this->connection();
|
||||
$now = now();
|
||||
$rows = [];
|
||||
$occurrences = [];
|
||||
|
||||
foreach ($payload as $index => $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$process = trim((string) ($item['process'] ?? ''));
|
||||
$source = self::SOURCES[$process] ?? null;
|
||||
if ($source === null) {
|
||||
throw new RuntimeException('未知的台账流程:' . $process);
|
||||
}
|
||||
|
||||
$date = $this->date($item['date'] ?? null);
|
||||
if ($date === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$approvalNo = $this->text($item['process_no'] ?? '');
|
||||
$rawAccount = $this->text($item['account_short'] ?? '');
|
||||
$accountName = $this->text($item['acct_name'] ?? '');
|
||||
$sourceValue = $source === 'dingtalk' ? $accountName : $rawAccount;
|
||||
$income = round($this->number($item['income'] ?? 0), 2);
|
||||
$expense = round($this->number($item['expense'] ?? 0), 2);
|
||||
$detail = $this->text($item['detail'] ?? '', 4000);
|
||||
|
||||
$canonical = implode("\0", [
|
||||
$source,
|
||||
$this->text($item['pid'] ?? ''),
|
||||
$this->text($item['seq'] ?? ''),
|
||||
$approvalNo,
|
||||
$this->text($item['acct_code'] ?? ''),
|
||||
$accountName,
|
||||
number_format($income, 2, '.', ''),
|
||||
number_format($expense, 2, '.', ''),
|
||||
$detail,
|
||||
]);
|
||||
$occurrences[$canonical] = ($occurrences[$canonical] ?? 0) + 1;
|
||||
|
||||
$rows[] = [
|
||||
'txn_date' => $date,
|
||||
'account_short' => $rawAccount,
|
||||
'detail' => $detail,
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'approval_no' => $approvalNo,
|
||||
'source' => $source,
|
||||
'source_value' => $sourceValue,
|
||||
'process_type' => $process,
|
||||
'row_key' => substr(hash('sha256', $canonical), 0, 56) . ':' . $occurrences[$canonical],
|
||||
'synced_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
throw new RuntimeException('台账快照没有可导入的动账明细。');
|
||||
}
|
||||
|
||||
$connection->transaction(function () use ($connection, $rows, $now): void {
|
||||
$connection->table('fin_transactions')->delete();
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
$connection->table('fin_transactions')->insert($chunk);
|
||||
}
|
||||
$this->refreshMappingStats($connection, $now);
|
||||
});
|
||||
|
||||
$result = [
|
||||
'rows' => count($rows),
|
||||
'income' => round(array_sum(array_column($rows, 'income')), 2),
|
||||
'expense' => round(array_sum(array_column($rows, 'expense')), 2),
|
||||
'from' => min(array_column($rows, 'txn_date')),
|
||||
'to' => max(array_column($rows, 'txn_date')),
|
||||
];
|
||||
|
||||
if ($recalculate) {
|
||||
$result['recalculated'] = app(FinanceLedgerCalculator::class)->recalculate();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function refreshMappingStats($connection, $now): void
|
||||
{
|
||||
$stats = $connection->table('fin_transactions')
|
||||
->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum')
|
||||
->whereNotNull('source_value')
|
||||
->where('source_value', '<>', '')
|
||||
->groupBy('source', 'source_value')
|
||||
->get();
|
||||
|
||||
$rows = [];
|
||||
foreach ($stats as $stat) {
|
||||
$rows[] = [
|
||||
'source' => (string) $stat->source,
|
||||
'source_value' => mb_substr((string) $stat->source_value, 0, 200),
|
||||
'txn_count' => (int) $stat->txn_count,
|
||||
'income_sum' => round((float) $stat->income_sum, 2),
|
||||
'expense_sum' => round((float) $stat->expense_sum, 2),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
$connection->table('fin_account_map')->upsert(
|
||||
$chunk,
|
||||
['source', 'source_value'],
|
||||
['txn_count', 'income_sum', 'expense_sum', 'updated_at'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function connection()
|
||||
{
|
||||
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
}
|
||||
|
||||
private function text(mixed $value, int $max = 200): string
|
||||
{
|
||||
$text = trim((string) ($value ?? ''));
|
||||
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
||||
|
||||
return mb_substr($text, 0, $max);
|
||||
}
|
||||
|
||||
private function number(mixed $value): float
|
||||
{
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (float) $value;
|
||||
}
|
||||
$text = str_replace([',', ',', '¥', '¥', ' '], '', trim((string) ($value ?? '')));
|
||||
|
||||
return is_numeric($text) ? (float) $text : 0.0;
|
||||
}
|
||||
|
||||
private function date(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) ($value ?? ''));
|
||||
if (preg_match('/^(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})/', $text, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $matches[1], $matches[2], $matches[3]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 动账明细写入器:全量替换 fin_transactions,刷新映射统计并重算流水。
|
||||
*
|
||||
* 同一笔业务在相同输入下生成稳定的 row_key;完全重复的行保留出现序号,
|
||||
* 以保持规格书 12,037 笔的口径。
|
||||
*/
|
||||
class FinanceLedgerTransactionWriter
|
||||
{
|
||||
public function replace(array $transactions, bool $recalculate = true): array
|
||||
{
|
||||
if ($transactions === []) {
|
||||
throw new RuntimeException('没有可写入的动账明细。');
|
||||
}
|
||||
|
||||
$connection = $this->connection();
|
||||
$now = now();
|
||||
$rows = [];
|
||||
$occurrences = [];
|
||||
|
||||
foreach ($transactions as $item) {
|
||||
$date = $this->date($item['txn_date'] ?? null);
|
||||
if ($date === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = (string) ($item['source'] ?? '');
|
||||
if ($source === '') {
|
||||
throw new RuntimeException('动账明细缺少 source。');
|
||||
}
|
||||
|
||||
$income = round((float) ($item['income'] ?? 0), 2);
|
||||
$expense = round((float) ($item['expense'] ?? 0), 2);
|
||||
$detail = mb_substr($this->normalizeText($item['detail'] ?? ''), 0, 4000);
|
||||
$approvalNo = mb_substr(trim((string) ($item['approval_no'] ?? '')), 0, 100);
|
||||
$sourceValue = mb_substr($this->normalizeText($item['source_value'] ?? ''), 0, 200);
|
||||
$rawAccount = mb_substr($this->normalizeText($item['account_short'] ?? ''), 0, 100);
|
||||
$processType = mb_substr(trim((string) ($item['process_type'] ?? '')), 0, 50);
|
||||
$pid = trim((string) ($item['pid'] ?? ''));
|
||||
$seq = trim((string) ($item['seq'] ?? ''));
|
||||
$acctCode = trim((string) ($item['acct_code'] ?? ''));
|
||||
$acctName = trim((string) ($item['acct_name'] ?? ''));
|
||||
|
||||
$canonical = implode("\0", [
|
||||
$source, $processType, $pid, $seq, $approvalNo, $acctCode, $acctName,
|
||||
number_format($income, 2, '.', ''), number_format($expense, 2, '.', ''), $detail,
|
||||
]);
|
||||
$occurrences[$canonical] = ($occurrences[$canonical] ?? 0) + 1;
|
||||
|
||||
$rows[] = [
|
||||
'txn_date' => $date,
|
||||
'account_short' => $rawAccount,
|
||||
'detail' => $detail,
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'approval_no' => $approvalNo,
|
||||
'source' => $source,
|
||||
'source_value' => $sourceValue,
|
||||
'process_type' => $processType,
|
||||
'row_key' => substr(hash('sha256', $canonical), 0, 56) . ':' . $occurrences[$canonical],
|
||||
'synced_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
throw new RuntimeException('动账明细没有有效日期。');
|
||||
}
|
||||
|
||||
$connection->transaction(function () use ($connection, $rows, $now): void {
|
||||
$connection->table('fin_transactions')->delete();
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
$connection->table('fin_transactions')->insert($chunk);
|
||||
}
|
||||
$this->refreshMappingStats($connection, $now);
|
||||
});
|
||||
|
||||
$result = [
|
||||
'rows' => count($rows),
|
||||
'income' => round(array_sum(array_column($rows, 'income')), 2),
|
||||
'expense' => round(array_sum(array_column($rows, 'expense')), 2),
|
||||
'from' => min(array_column($rows, 'txn_date')),
|
||||
'to' => max(array_column($rows, 'txn_date')),
|
||||
];
|
||||
|
||||
if ($recalculate) {
|
||||
$result['recalculated'] = app(FinanceLedgerCalculator::class)->recalculate();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function refreshMappingStats($connection, $now): void
|
||||
{
|
||||
$stats = $connection->table('fin_transactions')
|
||||
->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum')
|
||||
->whereNotNull('source_value')
|
||||
->where('source_value', '<>', '')
|
||||
->groupBy('source', 'source_value')
|
||||
->get();
|
||||
|
||||
$rows = [];
|
||||
$keys = [];
|
||||
foreach ($stats as $stat) {
|
||||
$source = (string) $stat->source;
|
||||
$value = mb_substr((string) $stat->source_value, 0, 200);
|
||||
$keys[$source . "\0" . $value] = true;
|
||||
$rows[] = [
|
||||
'source' => $source,
|
||||
'source_value' => $value,
|
||||
'txn_count' => (int) $stat->txn_count,
|
||||
'income_sum' => round((float) $stat->income_sum, 2),
|
||||
'expense_sum' => round((float) $stat->expense_sum, 2),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
// 上游删除或改写的来源值不能继续留在映射表里;只清理本次未出现的行,
|
||||
// 保留已有 account_short,避免重同步覆盖人工映射。
|
||||
$stale = [];
|
||||
foreach ($connection->table('fin_account_map')->get(['id', 'source', 'source_value']) as $existing) {
|
||||
if (! isset($keys[(string) $existing->source . "\0" . (string) $existing->source_value])) {
|
||||
$stale[] = (int) $existing->id;
|
||||
}
|
||||
}
|
||||
foreach (array_chunk($stale, 500) as $ids) {
|
||||
$connection->table('fin_account_map')->whereIn('id', $ids)->delete();
|
||||
}
|
||||
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
$connection->table('fin_account_map')->upsert(
|
||||
$chunk,
|
||||
['source', 'source_value'],
|
||||
['txn_count', 'income_sum', 'expense_sum', 'updated_at'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeText(mixed $value): string
|
||||
{
|
||||
$text = trim((string) ($value ?? ''));
|
||||
|
||||
return preg_replace('/\s+/u', ' ', $text) ?? $text;
|
||||
}
|
||||
|
||||
private function connection()
|
||||
{
|
||||
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
}
|
||||
|
||||
private function date(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) ($value ?? ''));
|
||||
if (preg_match('/^(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})/', $text, $matches) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $matches[1], $matches[2], $matches[3]);
|
||||
}
|
||||
}
|
||||
@ -73,6 +73,8 @@ class ShippingReplicaSynchronizer
|
||||
private int $packagesRetainedForUnmatchedAmount = 0;
|
||||
private int $staleMirrorPackages = 0;
|
||||
private int $supersededRemoved = 0;
|
||||
private int $outboundScanned = 0;
|
||||
private int $purchaseScanned = 0;
|
||||
|
||||
/** @var array<int,true> */
|
||||
private array $seenSourceIds = [];
|
||||
@ -183,6 +185,8 @@ class ShippingReplicaSynchronizer
|
||||
$this->packagesRetainedForUnmatchedAmount = 0;
|
||||
$this->staleMirrorPackages = 0;
|
||||
$this->supersededRemoved = 0;
|
||||
$this->outboundScanned = 0;
|
||||
$this->purchaseScanned = 0;
|
||||
$this->seenSourceIds = [];
|
||||
$this->expressOrderIds = [];
|
||||
$this->dwsFailedDays = [];
|
||||
@ -857,6 +861,7 @@ class ShippingReplicaSynchronizer
|
||||
[$a, $a + $step, $fromS, $toS]
|
||||
);
|
||||
$scanned += count($rows);
|
||||
$this->outboundScanned += count($rows);
|
||||
$pick = [];
|
||||
foreach ($rows as $r) {
|
||||
$io = (string) ($r['io_id'] ?? '');
|
||||
@ -959,6 +964,7 @@ class ShippingReplicaSynchronizer
|
||||
. 'receiver_address, receiver_name, receiver_mobile FROM ' . self::PURCHASE_OUT . ' WHERE io_date >= ? AND io_date < ?',
|
||||
[$fromS, $toS]
|
||||
);
|
||||
$this->purchaseScanned += count($rows);
|
||||
$pick = [];
|
||||
$skip = ['done' => 0, 'cancel' => 0, 'noitem' => 0];
|
||||
foreach ($rows as $r) {
|
||||
@ -1286,15 +1292,16 @@ class ShippingReplicaSynchronizer
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作平台的快递名称/仓库即发货数据视图的 expressName / wareHoursName(抽样 14/14 一致):
|
||||
* 视图有值时以视图为准;视图无该出库单时保留出库单/发货明细推断的值。
|
||||
* 仓库以发货数据视图的 wareHoursName 为准(工作平台口径);快递名称以出库单/发货明细当前值为准,视图仅兜底。
|
||||
*/
|
||||
private function withViewCarrier(string $io, array $pkg, int $pkgId): array
|
||||
{
|
||||
if ($io === '') {
|
||||
return $pkg;
|
||||
}
|
||||
if (! empty($this->ioExpress[$io])) {
|
||||
// 快递名称以出库单/发货明细的当前值为准:聚水潭换单、改快递公司后出库单与发货明细会更新,工作平台跟着变,
|
||||
// 发货数据视图的 expressName 却停在旧值(如 崔桥顺丰干配→常州德邦)。视图只在出库单/发货明细没有快递名称时兜底。
|
||||
if (trim((string) ($pkg['express_name1'] ?? '')) === '' && ! empty($this->ioExpress[$io])) {
|
||||
$pkg['express_name1'] = $this->ioExpress[$io];
|
||||
}
|
||||
if (! empty($this->ioWare[$io])) {
|
||||
@ -2389,6 +2396,12 @@ class ShippingReplicaSynchronizer
|
||||
}
|
||||
// 凌晨按自动发货仓入库、当时还没有快递单号的旧包裹:订单之后有了发货明细(改走发货明细来源键),
|
||||
// 或同一交易单号本次已由其它包裹吐出 → 视为被取代
|
||||
// 出库单兜底/采购退货出库:本次已完整扫描窗口内的出库单与采购退货单,未再出现说明上游已作废、换单或不再收录(工作平台也不再显示)
|
||||
if (($id >= self::PURCHASE_OUT_OFFSET && $this->purchaseScanned > 0)
|
||||
|| ($id >= self::OUTBOUND_OFFSET && $id < self::PURCHASE_OUT_OFFSET && $this->outboundScanned > 0)) {
|
||||
$superseded[] = $id;
|
||||
continue;
|
||||
}
|
||||
$so = trim((string) ($row->trade_no ?? ''));
|
||||
$isAuto = $id >= self::AUTOSHIP_OFFSET && $id < self::OUTBOUND_OFFSET;
|
||||
if (($isAuto && isset($this->expressOrderIds[(string) ($id - self::AUTOSHIP_OFFSET)]))
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -76,10 +76,13 @@ final class LedgerRemarkFilter
|
||||
if ($plan['prefixes'] !== [] || $plan['excludes'] !== []) {
|
||||
$sub->where(function (Builder $scope) use ($plan, $storeLike, $storeNotLike): void {
|
||||
if ($plan['prefixes'] !== []) {
|
||||
// 本平台前缀的店铺
|
||||
// 同平台可能有多个店铺名前缀(如阿里巴巴 = 阿里巴巴 / 1688 / 新佰广),
|
||||
// 任意一个前缀命中即可,不能把多个 LIKE 条件用 AND 串起来。
|
||||
$scope->orWhere(function (Builder $own) use ($plan, $storeLike): void {
|
||||
foreach ($plan['prefixes'] as $prefix) {
|
||||
$storeLike($own, $prefix);
|
||||
$own->orWhere(function (Builder $one) use ($storeLike, $prefix): void {
|
||||
$storeLike($one, $prefix);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
336
modules/EcommerceFinance/Services/PlantingLossCalculator.php
Normal file
336
modules/EcommerceFinance/Services/PlantingLossCalculator.php
Normal file
@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository;
|
||||
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”的不扣款,不论是否匹配到发货都单独列出发给运营;
|
||||
* 4. 实际只发了「信誉订单虚拟条码」「透明挂钩」这类商品的不扣款;
|
||||
* 5. 其余匹配到的作为扣款项,扣款金额 = Σ 发货商品成本价 × 数量 + 该订单在发货数据中的运费(2026-10-19 更正),
|
||||
* 成本价按发货条形码(=商品编码)取普通商品资料。未匹配到发货的不计入种菜损耗。
|
||||
*/
|
||||
class PlantingLossCalculator
|
||||
{
|
||||
public const RESULT_DEDUCT = '扣款';
|
||||
|
||||
public const RESULT_EXEMPT = '不扣款(拍A发B/拍A发A)';
|
||||
|
||||
public const RESULT_FREE_GOODS = '不扣款(虚拟条码/透明挂钩)';
|
||||
|
||||
public const RESULT_UNMATCHED = '未匹配发货';
|
||||
|
||||
public const RESULTS = [self::RESULT_DEDUCT, self::RESULT_EXEMPT, self::RESULT_FREE_GOODS, self::RESULT_UNMATCHED];
|
||||
|
||||
/** 发货商品名称或条码含这些字样的不计扣款。 */
|
||||
public const FREE_GOODS_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩', '无实物'];
|
||||
|
||||
/**
|
||||
* 信誉订单虚拟条码(种菜单自身的虚拟商品)。发货数据里同一交易单号下若出现它,说明这单本身是按虚拟条码走的,
|
||||
* 同单号下的其它商品是合并发货的别的订单,整单不扣款(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';
|
||||
|
||||
private const CHUNK = 1000;
|
||||
|
||||
private const CACHE_SECONDS = 600;
|
||||
|
||||
public function __construct(
|
||||
private readonly PlantingReimbursementRepository $reimbursements,
|
||||
private readonly ShippingOrderRepository $shipping,
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
) {}
|
||||
|
||||
/** 发货数据同步状态(与「发货数据」页面同一来源)。 */
|
||||
public function shippingStatus(): array
|
||||
{
|
||||
return $this->shipping->syncStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发货数据匹配窗口:报销月份的上月 1 日 00:00 至下月 1 日 00:00。
|
||||
*
|
||||
* @return array{from:string,to:string}
|
||||
*/
|
||||
public static function shippingWindow(string $month): array
|
||||
{
|
||||
PlantingReimbursementRepository::window($month);
|
||||
$start = CarbonImmutable::createFromFormat('!Y-m', $month);
|
||||
|
||||
return [
|
||||
'from' => $start->subMonthNoOverflow()->format('Y-m-d H:i:s'),
|
||||
'to' => $start->addMonthNoOverflow()->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/** 例:2026-09 → “2026-08~2026-09”。 */
|
||||
public static function shippingLabel(string $month): string
|
||||
{
|
||||
$window = self::shippingWindow($month);
|
||||
|
||||
return substr($window['from'], 0, 7) . '~' . $month;
|
||||
}
|
||||
|
||||
/** 命中的免扣字样(拍A发B / 拍A发A),未命中返回 null。全角、大小写、空格均兼容。 */
|
||||
public static function exemptionKeyword(string ...$remarks): ?string
|
||||
{
|
||||
foreach ($remarks as $remark) {
|
||||
$text = mb_strtoupper((string) preg_replace('/\s+/u', '', mb_convert_kana($remark, 'as')));
|
||||
if (preg_match('/拍A发([AB])/u', $text, $match) === 1) {
|
||||
return '拍A发' . $match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function isFreeGoods(string $barcode, string $productName): bool
|
||||
{
|
||||
foreach (self::FREE_GOODS_KEYWORDS as $keyword) {
|
||||
if (str_contains($productName, $keyword) || str_contains($barcode, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当月全部种菜报销及其发货匹配、扣款判定(含未匹配,页面按结果筛选)。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function rows(string $month): array
|
||||
{
|
||||
$key = 'planting-loss:rows:v3:' . $month;
|
||||
$cached = Cache::get($key);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$reimbursements = $this->reimbursements->rows($month);
|
||||
$window = self::shippingWindow($month);
|
||||
$shipments = $this->shipments(array_column($reimbursements, 'online_order_no'), $window['from'], $window['to']);
|
||||
$costs = $this->costPrices(array_merge(...array_map(
|
||||
static fn (array $shipment): array => array_column($shipment['lines'], 'barcode'),
|
||||
array_values($shipments),
|
||||
) ?: [[]]));
|
||||
|
||||
$rows = [];
|
||||
foreach ($reimbursements as $row) {
|
||||
$rows[] = $this->judge($row, $shipments[$row['online_order_no']] ?? null, $costs, $month);
|
||||
}
|
||||
|
||||
try {
|
||||
Cache::put($key, $rows, self::CACHE_SECONDS);
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @param array<string,mixed>|null $shipment
|
||||
* @param array<string,string> $costs 条码 => 成本价
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function judge(array $row, ?array $shipment, array $costs, string $month): array
|
||||
{
|
||||
$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;
|
||||
}
|
||||
$cost = $costs[$line['barcode']] ?? null;
|
||||
if ($cost === null) {
|
||||
$missingCost[] = $line['barcode'] !== '' ? $line['barcode'] : '(无条码)';
|
||||
$deductLines[] = $line['barcode'] . ' × ' . $line['qty'] . ' 成本价缺失';
|
||||
continue;
|
||||
}
|
||||
$lineCents = self::deductionCents($cost, $line['qty']);
|
||||
$cents += $lineCents;
|
||||
$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 ? '运费缺失' : '',
|
||||
]));
|
||||
|
||||
// 拍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) . ' 发货数据中无此交易单号'],
|
||||
$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 && $missingText !== '';
|
||||
$row['shipped_at'] = $shipment['shipped_at'] ?? '';
|
||||
$row['express_no'] = $shipment['express_no'] ?? '';
|
||||
$row['warehouse_name'] = $shipment['warehouse_name'] ?? '';
|
||||
$row['shipped_barcode'] = implode("\n", array_column($shipment['lines'] ?? [], 'barcode'));
|
||||
$row['shipped_product_name'] = implode("\n", array_column($shipment['lines'] ?? [], 'product_name'));
|
||||
$row['shipped_qty'] = $shipment === null ? '' : (string) array_sum(array_column($shipment['lines'], 'qty'));
|
||||
$row['shipped_amount'] = $shipment['amount'] ?? null;
|
||||
$row['shipped_freight'] = $shipment['freight'] ?? null;
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $tradeNos
|
||||
* @return array<string,array{shipped_at:string,express_no:string,warehouse_name:string,amount:?string,freight:?string,lines:list<array{barcode:string,product_name:string,qty:int}>}>
|
||||
*/
|
||||
private function shipments(array $tradeNos, string $from, string $to): array
|
||||
{
|
||||
$tradeNos = array_values(array_unique(array_filter(array_map('trim', $tradeNos), static fn (string $v): bool => $v !== '')));
|
||||
$db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
|
||||
|
||||
$acc = [];
|
||||
foreach (array_chunk($tradeNos, self::CHUNK) as $chunk) {
|
||||
$records = $db->table('jst_oms_shipping')
|
||||
->whereIn('trade_no', $chunk)
|
||||
->where('ship_time', '>=', $from)
|
||||
->where('ship_time', '<', $to)
|
||||
->orderBy('id')
|
||||
->get(['trade_no', 'ship_time', 'express_no', 'warehouse_name', 'barcode', 'product_name', 'qty', 'amount', 'freight']);
|
||||
foreach ($records as $record) {
|
||||
$trade = (string) $record->trade_no;
|
||||
$item = &$acc[$trade];
|
||||
$shipTime = substr((string) $record->ship_time, 0, 19);
|
||||
$item['shipped_at'] = isset($item['shipped_at']) ? min($item['shipped_at'], $shipTime) : $shipTime;
|
||||
foreach (['express_no', 'warehouse_name'] as $key) {
|
||||
$value = trim((string) $record->{$key});
|
||||
if ($value !== '') {
|
||||
$item[$key][$value] = true;
|
||||
}
|
||||
}
|
||||
foreach (['amount', 'freight'] as $key) {
|
||||
if ($record->{$key} !== null) {
|
||||
$item[$key] = ($item[$key] ?? 0) + (int) round((float) $record->{$key} * 100);
|
||||
}
|
||||
}
|
||||
$barcode = trim((string) $record->barcode);
|
||||
$lineKey = $barcode . '|' . trim((string) $record->product_name);
|
||||
$item['lines'][$lineKey] ??= ['barcode' => $barcode, 'product_name' => trim((string) $record->product_name), 'qty' => 0];
|
||||
$item['lines'][$lineKey]['qty'] += max(1, (int) $record->qty);
|
||||
unset($item);
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($acc as $trade => $item) {
|
||||
$result[$trade] = [
|
||||
'shipped_at' => $item['shipped_at'],
|
||||
'express_no' => implode("\n", array_keys($item['express_no'] ?? [])),
|
||||
'warehouse_name' => implode("\n", array_keys($item['warehouse_name'] ?? [])),
|
||||
'amount' => isset($item['amount']) ? number_format($item['amount'] / 100, 2, '.', '') : null,
|
||||
'freight' => isset($item['freight']) ? number_format($item['freight'] / 100, 2, '.', '') : null,
|
||||
'lines' => array_values($item['lines']),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 普通商品资料的成本价:条形码(商品编码)= ops_jst_sku.sku_id,同编码取最新一条。
|
||||
*
|
||||
* @param list<string> $barcodes
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function costPrices(array $barcodes): array
|
||||
{
|
||||
$barcodes = array_values(array_unique(array_filter($barcodes, static fn (string $v): bool => $v !== '')));
|
||||
if ($barcodes === []) {
|
||||
return [];
|
||||
}
|
||||
$connection = $this->mirror->connection();
|
||||
$table = $this->datasets->get(self::SKU_DATASET)->targetTable();
|
||||
|
||||
$costs = [];
|
||||
foreach (array_chunk($barcodes, self::CHUNK) as $chunk) {
|
||||
$records = $connection->table($table)
|
||||
->whereIn('sku_id', $chunk)
|
||||
->where('sku_type', 'normal')
|
||||
->orderByDesc('autoid')
|
||||
->get(['sku_id', 'cost_price']);
|
||||
foreach ($records as $record) {
|
||||
$sku = (string) $record->sku_id;
|
||||
if (isset($costs[$sku])) {
|
||||
continue;
|
||||
}
|
||||
// 成本价为空或 0 视为缺失,避免把未维护成本的商品算成 0 元扣款。
|
||||
if (is_numeric($record->cost_price) && (float) $record->cost_price > 0) {
|
||||
$costs[$sku] = rtrim(rtrim(number_format((float) $record->cost_price, 4, '.', ''), '0'), '.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $costs;
|
||||
}
|
||||
}
|
||||
@ -80,7 +80,7 @@ class StoreReportLogic
|
||||
[
|
||||
'code' => 'R9',
|
||||
'object' => '销量口径',
|
||||
'logic' => '销量=业绩明细(店铺收款匹配订单明细)的「订单实付/实收金额」,按「店铺名称 + 发生时间」汇总;订单实付为空的未匹配行不计入。收入列仍按平台账单日汇总,两个口径分开统计、不互相覆盖。',
|
||||
'logic' => '销量=业绩明细(店铺收款匹配订单明细)的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按「店铺名称 + 发生日期」逐日汇总;退款、冲减按账单净额(负数)计入,不再使用订单实付/实收金额。收入列仍按平台账单日汇总,两个口径分开统计、不互相覆盖。',
|
||||
'source' => 'store_receipt_order_match_rows → store_report_paid_sales_rows',
|
||||
'priority' => '与业绩明细同口径',
|
||||
],
|
||||
|
||||
@ -447,7 +447,8 @@ class StoreReportMirrorSynchronizer
|
||||
}
|
||||
|
||||
/**
|
||||
* 店铺维度报表的「销量」:把业绩明细的「订单实付/实收金额」按「店铺名称 + 发生日期」预汇总。
|
||||
* 店铺维度报表的「销量」:把业绩明细的
|
||||
* 「收入金额 + 优惠金额 + 冲减金额 + 退款金额」按「店铺名称 + 发生日期」预汇总。
|
||||
*
|
||||
* 业绩明细由 store-receipt:order-match:sync 按「账期 + 平台」整体重写,这里用
|
||||
* 「行数 + 最近写入时间 + 金额合计」的指纹判断是否变化:没变化就复用当前版本,
|
||||
@ -456,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)) {
|
||||
@ -463,53 +473,66 @@ class StoreReportMirrorSynchronizer
|
||||
}
|
||||
|
||||
$state = $this->state(StoreReportRepository::PAID_SALES_DATASET);
|
||||
$changes = app(StoreReportPaidSalesChanges::class);
|
||||
try {
|
||||
$fingerprint = $this->paidSalesFingerprint();
|
||||
$active = trim((string) ($state->active_generation ?? ''));
|
||||
if (! $force && $active !== '' && ! empty($state->initial_completed_at)
|
||||
&& (string) ($state->source_fingerprint ?? '') === $fingerprint) {
|
||||
return [
|
||||
'status' => 'succeeded',
|
||||
'source_rows' => 0,
|
||||
'stored_rows' => (int) ($state->row_count ?? 0),
|
||||
'changed' => false,
|
||||
];
|
||||
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) {
|
||||
return [
|
||||
'status' => 'succeeded',
|
||||
'source_rows' => 0,
|
||||
'stored_rows' => (int) ($state->row_count ?? 0),
|
||||
'changed' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$generation = (string) Str::uuid();
|
||||
$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(paid_amount, 0)), COUNT(paid_amount), ?'
|
||||
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE
|
||||
. " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IS NOT NULL"
|
||||
. ' GROUP BY store_name, occurred_date',
|
||||
[$generation, $now->format('Y-m-d H:i:s')],
|
||||
);
|
||||
$stored = (int) $this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
|
||||
->where('generation', $generation)->count();
|
||||
$sourceRows = (int) ($this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)->count());
|
||||
$this->connection->table('store_report_import_states')
|
||||
->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)
|
||||
->update([
|
||||
'status' => 'succeeded',
|
||||
'run_mode' => 'full',
|
||||
'active_generation' => $generation,
|
||||
'pending_generation' => null,
|
||||
'row_count' => $stored,
|
||||
'source_fingerprint' => $fingerprint,
|
||||
'initial_completed_at' => $state->initial_completed_at ?? $now,
|
||||
'last_attempted_at' => $now,
|
||||
'last_succeeded_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
// 版本切换成功后再清理上一版,避免中途失败时页面读到被清空的当前版本。
|
||||
$this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
|
||||
->where('generation', '<>', $generation)->delete();
|
||||
$now = now();
|
||||
$generation = (string) Str::uuid();
|
||||
$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 IS NOT NULL"
|
||||
. ' GROUP BY store_name, occurred_date',
|
||||
[$generation, $now->format('Y-m-d H:i:s')],
|
||||
);
|
||||
$stored = (int) $this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
|
||||
->where('generation', $generation)->count();
|
||||
$sourceRows = (int) ($this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)->count());
|
||||
$this->connection->table('store_report_import_states')
|
||||
->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)
|
||||
->update([
|
||||
'status' => 'succeeded',
|
||||
'run_mode' => 'full',
|
||||
'active_generation' => $generation,
|
||||
'pending_generation' => null,
|
||||
'row_count' => $stored,
|
||||
'source_fingerprint' => $fingerprint,
|
||||
'initial_completed_at' => $state->initial_completed_at ?? $now,
|
||||
'last_attempted_at' => $now,
|
||||
'last_succeeded_at' => $now,
|
||||
'last_error_code' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
// 版本切换成功后再清理上一版,避免中途失败时页面读到被清空的当前版本。
|
||||
$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];
|
||||
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)
|
||||
@ -529,18 +552,55 @@ 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
|
||||
{
|
||||
$row = $this->connection->selectOne(
|
||||
'SELECT COUNT(*) AS rows_total, MAX(imported_at) AS last_imported_at,'
|
||||
. ' COALESCE(SUM(paid_amount), 0) AS paid_total FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE,
|
||||
. ' COALESCE(SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
|
||||
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), 0) AS sales_total'
|
||||
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE,
|
||||
);
|
||||
|
||||
return hash('sha256', implode('|', [
|
||||
(string) ($row->rows_total ?? 0),
|
||||
(string) ($row->last_imported_at ?? ''),
|
||||
(string) ($row->paid_total ?? '0'),
|
||||
(string) ($row->sales_total ?? '0'),
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@ -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,121 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
|
||||
/**
|
||||
* 读取厂家记账配置 Excel:
|
||||
* - 财务原表:供应商、厂家是否记账(原文如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”);
|
||||
* - 本系统导出表:供应商、记账方式、生效开始日期、生效结束日期、原始说明。
|
||||
*/
|
||||
class SupplierBookkeepingConfigImporter
|
||||
{
|
||||
/** @return list<array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text:string}> */
|
||||
public function read(string $path): array
|
||||
{
|
||||
$reader = IOFactory::createReader('Xlsx');
|
||||
$info = $reader->listWorksheetInfo($path);
|
||||
if ($info === [] || $info[0]['totalRows'] > 5001 || $info[0]['totalColumns'] > 20) {
|
||||
throw ValidationException::withMessages(['file' => '请使用第一个工作表存放配置,最多5000条。']);
|
||||
}
|
||||
$reader->setLoadSheetsOnly($info[0]['worksheetName']);
|
||||
$book = $reader->load($path);
|
||||
try {
|
||||
$sheet = $book->getSheet(0);
|
||||
$headers = [];
|
||||
for ($column = 1; $column <= $info[0]['totalColumns']; $column++) {
|
||||
$headers[$this->label($sheet->getCell([$column, 1])->getValue())] = $column;
|
||||
}
|
||||
$supplierColumn = $headers['供应商'] ?? $headers['供应商名称'] ?? null;
|
||||
$ruleColumn = $headers['厂家是否记账'] ?? null;
|
||||
$modeColumn = $headers['记账方式'] ?? null;
|
||||
if ($supplierColumn === null || ($ruleColumn === null && $modeColumn === null)) {
|
||||
throw ValidationException::withMessages(['file' => '缺少列:需要“供应商”和“厂家是否记账”(或“记账方式”)。']);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$seen = [];
|
||||
for ($number = 2; $number <= $sheet->getHighestDataRow(); $number++) {
|
||||
$supplier = $this->text($sheet, $supplierColumn, $number);
|
||||
$ruleText = $ruleColumn !== null ? $this->text($sheet, $ruleColumn, $number) : '';
|
||||
$modeText = $modeColumn !== null ? $this->text($sheet, $modeColumn, $number) : '';
|
||||
// 原表中间重复出现的表头行、空行跳过。
|
||||
if (($supplier === '' && $ruleText === '' && $modeText === '') || $this->label($supplier) === '供应商') {
|
||||
continue;
|
||||
}
|
||||
if ($supplier === '' || mb_strlen($supplier) > 191) {
|
||||
throw ValidationException::withMessages(['file' => "第{$number}行供应商为空或超长。"]);
|
||||
}
|
||||
$key = mb_strtolower($supplier);
|
||||
if (isset($seen[$key])) {
|
||||
throw ValidationException::withMessages(['file' => "第{$number}行供应商“{$supplier}”与第{$seen[$key]}行重复。"]);
|
||||
}
|
||||
$seen[$key] = $number;
|
||||
|
||||
try {
|
||||
$parsed = Rule::parseRuleText($ruleText !== '' ? $ruleText : $modeText);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages(['file' => "第{$number}行:" . $exception->getMessage()]);
|
||||
}
|
||||
foreach (['effective_from' => '生效开始日期', 'effective_to' => '生效结束日期'] as $field => $label) {
|
||||
if (isset($headers[$label])) {
|
||||
$date = $this->date($sheet, $headers[$label], $number);
|
||||
if ($date !== null) {
|
||||
$parsed[$field] = $date;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($parsed['effective_from'] !== null && $parsed['effective_to'] !== null && $parsed['effective_from'] > $parsed['effective_to']) {
|
||||
throw ValidationException::withMessages(['file' => "第{$number}行生效结束日期早于开始日期。"]);
|
||||
}
|
||||
$source = isset($headers['原始说明']) ? $this->text($sheet, $headers['原始说明'], $number) : '';
|
||||
$rows[] = ['supplier_name' => $supplier] + $parsed + ['source_text' => $ruleText !== '' ? $ruleText : ($source !== '' ? $source : $modeText)];
|
||||
}
|
||||
if ($rows === []) {
|
||||
throw ValidationException::withMessages(['file' => '配置表没有有效记录。']);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
} finally {
|
||||
$book->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
private function label(mixed $value): string
|
||||
{
|
||||
return str_replace([' ', ' ', "\n"], '', trim((string) $value));
|
||||
}
|
||||
|
||||
private function text($sheet, int $column, int $row): string
|
||||
{
|
||||
$cell = $sheet->getCell([$column, $row]);
|
||||
if ($cell->getDataType() === 'f') {
|
||||
throw ValidationException::withMessages(['file' => "第{$row}行含公式,请使用实际配置值。"]);
|
||||
}
|
||||
|
||||
return trim((string) $cell->getFormattedValue());
|
||||
}
|
||||
|
||||
private function date($sheet, int $column, int $row): ?string
|
||||
{
|
||||
$cell = $sheet->getCell([$column, $row]);
|
||||
$value = $cell->getValue();
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return null;
|
||||
}
|
||||
if (is_numeric($value) && ExcelDate::isDateTime($cell)) {
|
||||
return ExcelDate::excelToDateTimeObject($value)->format('Y-m-d');
|
||||
}
|
||||
$text = str_replace(['/', '.'], '-', trim((string) $cell->getFormattedValue()));
|
||||
if (! preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $text, $m) || ! checkdate((int) $m[2], (int) $m[3], (int) $m[1])) {
|
||||
throw ValidationException::withMessages(['file' => "第{$row}行日期无效,请使用 2026-01-31 格式。"]);
|
||||
}
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $m[1], $m[2], $m[3]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
|
||||
|
||||
/** 供应商应付「厂家记账」配置:每个供应商一条记账方式,删除可恢复,操作留痕。 */
|
||||
class SupplierBookkeepingConfigStore
|
||||
{
|
||||
public const TABLE = 'business_supplier_bookkeeping_configs';
|
||||
public const EVENTS = 'business_supplier_bookkeeping_config_events';
|
||||
|
||||
private const FIELDS = ['mode', 'effective_from', 'effective_to', 'source_text'];
|
||||
|
||||
public function query()
|
||||
{
|
||||
return DB::table(self::TABLE);
|
||||
}
|
||||
|
||||
/** @return array<string,array<string,mixed>> 供应商名称 => 生效中的配置(未删除) */
|
||||
public function activeConfigs(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->query()->whereNull('deleted_at')->get() as $row) {
|
||||
$result[trim($row->supplier_name)] = (array) $row;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function filtered(array $filters)
|
||||
{
|
||||
$query = $this->query();
|
||||
$status = $filters['deleted_status'] ?? 'active';
|
||||
if ($status === 'deleted') {
|
||||
$query->whereNotNull('deleted_at');
|
||||
} elseif ($status !== 'all') {
|
||||
$query->whereNull('deleted_at');
|
||||
}
|
||||
if (($filters['supplier_name'] ?? '') !== '') {
|
||||
$query->where('supplier_name', 'like', '%' . addcslashes($filters['supplier_name'], '\\%_') . '%');
|
||||
}
|
||||
if (($filters['mode'] ?? '') !== '') {
|
||||
$query->where('mode', $filters['mode']);
|
||||
}
|
||||
|
||||
return $query->orderBy('supplier_name')->orderBy('id');
|
||||
}
|
||||
|
||||
/** @param array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text?:string} $data */
|
||||
public function save(array $data, string $actor, ?int $id = null): int
|
||||
{
|
||||
return DB::transaction(function () use ($data, $actor, $id): int {
|
||||
$before = $id === null ? null : $this->query()->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
|
||||
abort_if($id !== null && $before === null, 404);
|
||||
$name = trim($data['supplier_name']);
|
||||
$duplicate = $this->query()->where('supplier_name', $name)->when($id !== null, fn ($q) => $q->where('id', '<>', $id))->exists();
|
||||
if ($duplicate) {
|
||||
throw ValidationException::withMessages(['supplier_name' => '该供应商已有配置(含已删除),请编辑或恢复原配置。']);
|
||||
}
|
||||
$time = $this->time();
|
||||
$values = ['supplier_name' => $name, 'updated_at' => $time] + $this->values($data);
|
||||
if ($id === null) {
|
||||
$id = $this->query()->insertGetId($values + ['creator' => $actor, 'created_at' => $time]);
|
||||
} else {
|
||||
$this->query()->where('id', $id)->update($values);
|
||||
}
|
||||
$this->event($id, $actor, $before ? 'update' : 'create', ($before ? '修改' : '新增') . '厂家记账配置:' . $name . ' → ' . Rule::describe($values), ['before' => $before, 'after' => $values]);
|
||||
|
||||
return $id;
|
||||
});
|
||||
}
|
||||
|
||||
public function changeDeleted(int $id, bool $deleted, string $actor): void
|
||||
{
|
||||
DB::transaction(function () use ($id, $deleted, $actor): void {
|
||||
$row = $this->query()->where('id', $id)->lockForUpdate()->first();
|
||||
abort_if($row === null, 404);
|
||||
if (($row->deleted_at !== null) === $deleted) {
|
||||
return;
|
||||
}
|
||||
$this->query()->where('id', $id)->update(['deleted_at' => $deleted ? $this->time() : null, 'updated_at' => $this->time()]);
|
||||
$this->event($id, $actor, $deleted ? 'delete' : 'restore', ($deleted ? '删除' : '恢复') . '厂家记账配置:' . $row->supplier_name, ['supplier_name' => $row->supplier_name, 'mode' => $row->mode]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 整批导入:新供应商新增;已有供应商记账方式或生效期不同则按文件更新(已删除的一并恢复);相同则跳过。
|
||||
*
|
||||
* @param list<array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text:string}> $rows
|
||||
* @return array{added:int,updated:int,skipped:int}
|
||||
*/
|
||||
public function import(array $rows, string $actor, string $sourceHash): array
|
||||
{
|
||||
return DB::transaction(function () use ($rows, $actor, $sourceHash): array {
|
||||
$added = $updated = $skipped = 0;
|
||||
$time = $this->time();
|
||||
foreach ($rows as $row) {
|
||||
$name = trim($row['supplier_name']);
|
||||
$values = $this->values($row);
|
||||
$existing = $this->query()->where('supplier_name', $name)->lockForUpdate()->first();
|
||||
if ($existing === null) {
|
||||
$this->query()->insert(['supplier_name' => $name, 'creator' => $actor, 'created_at' => $time, 'updated_at' => $time] + $values);
|
||||
$added++;
|
||||
continue;
|
||||
}
|
||||
$same = $existing->deleted_at === null;
|
||||
foreach (['mode', 'effective_from', 'effective_to'] as $field) {
|
||||
$same = $same && (string) $existing->{$field} === (string) $values[$field];
|
||||
}
|
||||
if ($same) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$this->query()->where('id', $existing->id)->update($values + ['deleted_at' => null, 'updated_at' => $time]);
|
||||
$updated++;
|
||||
}
|
||||
$this->event(null, $actor, 'import', "导入厂家记账配置:新增{$added}条,更新{$updated}条,未变化{$skipped}条", compact('added', 'updated', 'skipped') + ['source_sha256' => $sourceHash]);
|
||||
|
||||
return compact('added', 'updated', 'skipped');
|
||||
});
|
||||
}
|
||||
|
||||
public function events()
|
||||
{
|
||||
return DB::table(self::EVENTS)->orderByDesc('id');
|
||||
}
|
||||
|
||||
private function values(array $data): array
|
||||
{
|
||||
$values = [];
|
||||
foreach (self::FIELDS as $field) {
|
||||
$value = $data[$field] ?? null;
|
||||
$values[$field] = $field === 'source_text' ? mb_substr(trim((string) $value), 0, 255) : (($value === '' || $value === null) ? null : (string) $value);
|
||||
}
|
||||
abort_unless(isset(Rule::MODES[$values['mode']]), 422, '记账方式无效。');
|
||||
if ($values['effective_from'] !== null && $values['effective_to'] !== null && $values['effective_from'] > $values['effective_to']) {
|
||||
throw ValidationException::withMessages(['effective_to' => '生效结束日期不能早于开始日期。']);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private function event(?int $id, string $actor, string $action, string $description, array $details): void
|
||||
{
|
||||
DB::table(self::EVENTS)->insert(['config_id' => $id, 'actor' => $actor, 'action' => $action, 'description' => mb_substr($description, 0, 1000), 'details' => json_encode($details, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'created_at' => $this->time()]);
|
||||
}
|
||||
|
||||
private function time(): string
|
||||
{
|
||||
return Carbon::now(config('app.platform_timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* 厂家记账扣款判定(2026-10-19 由财务确认):
|
||||
*
|
||||
* - 记账原因一级为“厂家记账”:按供应商的厂家记账配置判定,仅“正常记账”的供应商作为扣款项;
|
||||
* - 记账原因一级为“仓库记账”:只有订单发货仓库为“某某代发仓”且二级原因含“仓库发错货”时,
|
||||
* 才可作为厂家记账扣款项(供应商配置为“不记账”时仍不扣);其余仓库记账一律不作为扣款项;
|
||||
* - 配置表中没有的供应商(含配置不在生效期内)视同正常记账,作为可扣款项(2026-10-19 财务更正);
|
||||
* - 工单未带供应商、或多件商品分属不同供应商且判定不一致的,标记“待确认”,不计入扣款。
|
||||
*/
|
||||
class SupplierBookkeepingDeductionRule
|
||||
{
|
||||
public const MODE_NONE = 'none';
|
||||
public const MODE_DROPSHIP_WRONG = 'dropship_wrong';
|
||||
public const MODE_NORMAL = 'normal';
|
||||
|
||||
public const MODES = [
|
||||
self::MODE_NORMAL => '正常记账',
|
||||
self::MODE_DROPSHIP_WRONG => '仅代发仓发错货记账',
|
||||
self::MODE_NONE => '不记账',
|
||||
];
|
||||
|
||||
public const STATUS_DEDUCT = '扣款';
|
||||
public const STATUS_SKIP = '不扣款';
|
||||
public const STATUS_REVIEW = '待确认';
|
||||
|
||||
public const STATUSES = [self::STATUS_DEDUCT, self::STATUS_SKIP, self::STATUS_REVIEW];
|
||||
|
||||
public const DROPSHIP_WAREHOUSE_KEYWORD = '代发仓';
|
||||
public const WAREHOUSE_WRONG_KEYWORD = '仓库发错货';
|
||||
|
||||
/**
|
||||
* 解析配置表中“厂家是否记账”原文,例如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”。
|
||||
*
|
||||
* @return array{mode:string,effective_from:?string,effective_to:?string}
|
||||
*/
|
||||
public static function parseRuleText(string $text): array
|
||||
{
|
||||
$normalized = str_replace(['(', ')', ' ', ' '], ['(', ')', '', ''], trim($text));
|
||||
if ($normalized === '') {
|
||||
throw new InvalidArgumentException('厂家是否记账为空。');
|
||||
}
|
||||
$label = array_search($normalized, self::MODES, true);
|
||||
if (is_string($label)) {
|
||||
return ['mode' => $label, 'effective_from' => null, 'effective_to' => null];
|
||||
}
|
||||
if (str_contains($normalized, '代发仓') && (str_contains($normalized, '发货错') || str_contains($normalized, '发错货'))) {
|
||||
$mode = self::MODE_DROPSHIP_WRONG;
|
||||
} elseif (str_starts_with($normalized, '正常记账')) {
|
||||
$mode = self::MODE_NORMAL;
|
||||
} elseif (str_starts_with($normalized, '不记账')) {
|
||||
$mode = self::MODE_NONE;
|
||||
} else {
|
||||
throw new InvalidArgumentException('无法识别的记账方式:' . $text);
|
||||
}
|
||||
|
||||
[$from, $to] = [null, null];
|
||||
$date = '(\d{4})[.\/年](\d{1,2})月?(?:[.\/](\d{1,2})日?)?';
|
||||
if (preg_match('/(' . $date . '[-—~至]+' . $date . ')/u', $normalized, $m)) {
|
||||
$from = self::date((int) $m[1], (int) $m[2], ($m[3] ?? '') === '' ? 1 : (int) $m[3]);
|
||||
$to = self::date((int) $m[4], (int) $m[5], ($m[6] ?? '') === '' ? null : (int) $m[6]);
|
||||
if ($from > $to) {
|
||||
throw new InvalidArgumentException('生效期开始晚于结束:' . $text);
|
||||
}
|
||||
} elseif (str_contains($normalized, '(')) {
|
||||
throw new InvalidArgumentException('无法识别的生效期:' . $text);
|
||||
}
|
||||
|
||||
return ['mode' => $mode, 'effective_from' => $from, 'effective_to' => $to];
|
||||
}
|
||||
|
||||
/** 配置的展示文字,例如“不记账(2025-03-01 至 2026-02-28)”。 */
|
||||
public static function describe(array|object $config): string
|
||||
{
|
||||
$config = (array) $config;
|
||||
$label = self::MODES[$config['mode']] ?? (string) $config['mode'];
|
||||
$from = $config['effective_from'] ?? null;
|
||||
$to = $config['effective_to'] ?? null;
|
||||
if ($from === null && $to === null) {
|
||||
return $label;
|
||||
}
|
||||
|
||||
return $label . '(' . ($from ?? '不限') . ' 至 ' . ($to ?? '不限') . ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row 厂家记账明细行
|
||||
* @param array<string,array<string,mixed>|object> $configs 供应商名称 => 生效中的配置
|
||||
* @return array{supplier_config:string,deduction_status:string,deduction_basis:string}
|
||||
*/
|
||||
public static function decide(array $row, array $configs): array
|
||||
{
|
||||
$level1 = (string) ($row['reason_level_1'] ?? '');
|
||||
$level2 = (string) ($row['reason_level_2'] ?? '');
|
||||
$date = substr((string) ($row['started_at'] ?? ''), 0, 10);
|
||||
$suppliers = array_values(array_unique(array_filter(array_map('trim', explode("\n", (string) ($row['supplier'] ?? '')))), SORT_STRING));
|
||||
|
||||
$found = [];
|
||||
foreach ($suppliers as $supplier) {
|
||||
$found[$supplier] = isset($configs[$supplier]) ? (array) $configs[$supplier] : null;
|
||||
}
|
||||
$configText = implode("\n", array_map(static fn (?array $config): string => $config === null ? '未在配置表(按正常记账)' : self::describe($config), $found));
|
||||
|
||||
if ($level1 === '仓库记账') {
|
||||
$warehouse = (string) ($row['shipping_warehouse'] ?? '');
|
||||
if (! str_contains($warehouse, self::DROPSHIP_WAREHOUSE_KEYWORD) || ! str_contains($level2, self::WAREHOUSE_WRONG_KEYWORD)) {
|
||||
return self::result($configText, self::STATUS_SKIP, '仓库记账仅“代发仓 + 仓库发错货”作为厂家扣款,本单不扣');
|
||||
}
|
||||
} elseif ($level1 !== '厂家记账') {
|
||||
return self::result($configText, self::STATUS_SKIP, '非厂家记账/仓库记账原因');
|
||||
}
|
||||
|
||||
if ($suppliers === []) {
|
||||
return self::result('', self::STATUS_REVIEW, '工单未带供应商,无法判定扣款,请人工确认');
|
||||
}
|
||||
|
||||
$outcomes = [];
|
||||
foreach ($found as $supplier => $config) {
|
||||
$outcomes[$supplier] = self::outcome($level1, $config, $date);
|
||||
}
|
||||
$statuses = array_values(array_unique(array_column($outcomes, 0)));
|
||||
if (count($statuses) > 1) {
|
||||
$detail = implode(';', array_map(static fn (string $supplier, array $o): string => $supplier . ':' . $o[0], array_keys($outcomes), $outcomes));
|
||||
|
||||
return self::result($configText, self::STATUS_REVIEW, '多个供应商判定不一致,请人工确认(' . $detail . ')');
|
||||
}
|
||||
|
||||
return self::result($configText, $statuses[0], reset($outcomes)[1]);
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} [判定, 说明] */
|
||||
private static function outcome(string $level1, ?array $config, string $date): array
|
||||
{
|
||||
if ($config === null) {
|
||||
return [self::STATUS_DEDUCT, $level1 === '厂家记账' ? '供应商不在配置表,按正常记账扣款' : '代发仓发错货,记厂家扣款(供应商不在配置表)'];
|
||||
}
|
||||
$from = $config['effective_from'] ?? null;
|
||||
$to = $config['effective_to'] ?? null;
|
||||
if (($from !== null && $date < $from) || ($to !== null && $date > $to)) {
|
||||
return [self::STATUS_DEDUCT, '发起日期不在配置生效期内(' . self::describe($config) . '),按正常记账扣款'];
|
||||
}
|
||||
|
||||
return match ($config['mode']) {
|
||||
self::MODE_NONE => [self::STATUS_SKIP, '供应商配置为不记账'],
|
||||
self::MODE_NORMAL => $level1 === '厂家记账'
|
||||
? [self::STATUS_DEDUCT, '供应商正常记账']
|
||||
: [self::STATUS_DEDUCT, '代发仓发错货,记厂家扣款'],
|
||||
self::MODE_DROPSHIP_WRONG => $level1 === '仓库记账'
|
||||
? [self::STATUS_DEDUCT, '代发仓发错货,记厂家扣款']
|
||||
: [self::STATUS_SKIP, '供应商仅代发仓发错货记账,其他原因不记账'],
|
||||
default => [self::STATUS_REVIEW, '无法识别的记账方式,请人工确认'],
|
||||
};
|
||||
}
|
||||
|
||||
private static function result(string $config, string $status, string $basis): array
|
||||
{
|
||||
return ['supplier_config' => $config, 'deduction_status' => $status, 'deduction_basis' => $basis];
|
||||
}
|
||||
|
||||
private static function date(int $year, int $month, ?int $day): string
|
||||
{
|
||||
if ($month < 1 || $month > 12) {
|
||||
throw new InvalidArgumentException('生效期月份无效。');
|
||||
}
|
||||
$last = (int) date('t', mktime(0, 0, 0, $month, 1, $year));
|
||||
$day ??= $last;
|
||||
if ($day < 1 || $day > $last) {
|
||||
throw new InvalidArgumentException('生效期日期无效。');
|
||||
}
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
}
|
||||
@ -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[] = '';
|
||||
|
||||
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