Compare commits
134 Commits
feature/su
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| edc27345ee | |||
| 9976ca6b0a | |||
| ec3cd39413 | |||
| 9ab4e1e261 | |||
| 9028f6aff4 | |||
| cc0cdbeed7 | |||
| 66c5864455 | |||
| 873d306277 | |||
| 04034948e3 | |||
| ea1bff1d5a | |||
| c2a11b3064 | |||
| a6f4ca8ccc | |||
| 0942f31d7a | |||
| 18297f1845 | |||
| 9a7ecf879c | |||
| 0ab30d5bb9 | |||
| eaeae97752 | |||
| 36af0e211a | |||
| 639ff3d0b3 | |||
| 023342c6a8 | |||
| de770efce7 | |||
| 20c11719e0 | |||
| a1f921be2f | |||
| 1a7a647907 | |||
| 0e0ab438c0 | |||
| 8ba241a868 | |||
| e829ab2094 | |||
| 2186e9085a | |||
| a57a8116bf | |||
| 0b2bd343c5 | |||
| 345790175b | |||
| 36b8b5c616 | |||
| 176357871b | |||
| 6feea82305 | |||
| cd25916d9d | |||
| 20dc695549 | |||
| 93a27725b6 | |||
| 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 | |||
| ed58e8ef43 | |||
| b2dd1e5e6b | |||
| e428cf5de2 | |||
| 2d30aea624 | |||
| 409da4cae8 | |||
| da794c3ddb | |||
| b49d23d090 | |||
| 23887059f1 | |||
| 10d9c22e3e | |||
| bd39a7fdda | |||
| 077701d24a | |||
| d24eefb687 | |||
| 3638a8cee9 | |||
| 41f6946a2b | |||
| 3fedeef59a | |||
| a0a6e9285a | |||
| 18d03fe092 | |||
| b0d7096241 | |||
| c69796b0d3 | |||
| a8ffb6fc23 | |||
| 670faf2b80 | |||
| feea2ed8b6 | |||
| 2848c81a5e |
@ -10,6 +10,7 @@ runtime
|
||||
*.md
|
||||
JetBrainsMono-字体
|
||||
node_modules
|
||||
**/__pycache__
|
||||
vendor
|
||||
bootstrap/cache/*
|
||||
.caiwu-build
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -113,6 +113,7 @@ modules/*
|
||||
.workbuddy/
|
||||
runtime/.env
|
||||
/.private/kaifa-progress-sync/
|
||||
__pycache__/
|
||||
public/net-layer-demo.html
|
||||
public/perf-diff-plan.html
|
||||
public/plan-a-changes-202608.csv
|
||||
|
||||
11
AGENTS.md
11
AGENTS.md
@ -38,6 +38,15 @@
|
||||
- 数据页面按本次影响验证代表性数据量下的首页、翻页和筛选耗时;明显长时间等待不能以“返回正确”判定通过。低风险改动不增加重复或仅复述实现的测试。
|
||||
- 明确区分:本地已验证、已提交/推送、已合并、已上线和仍需生产验收。
|
||||
|
||||
### 性能与资源验收(强制)
|
||||
|
||||
- 涉及查询、报表、导入导出、同步或后台任务时,开发前先确认实际数据规模、单次数据量、文件大小和运行频率,并据此选择实现与验收数据;不能只验证几十行样例。
|
||||
- Excel/CSV 必须流式读取;表扫描、关联和排序使用合适索引,页面数据使用分页。分批写库只控制写入批次,不能据此声称读取过程内存受控。
|
||||
- 重型任务复用项目现有独立批处理容器,完成命令登记,并明确内存上限、超时、互斥和失败恢复;不得占用网页服务资源执行全量处理。
|
||||
- 禁止通过取消 `memory_limit`、无限重试、放大数据库参数或其他绕过资源边界的办法掩盖性能问题。
|
||||
- 使用代表性数据测量耗时、进程峰值内存和数据库开销,并在交付说明中写明测量范围、数据量及未验证项。生产环境不运行压测,不随意重跑全量任务。
|
||||
- 性能优化不得改变金额口径、筛选范围或现有业务结果;相关金额和功能回归必须同时通过。
|
||||
|
||||
## 5. 前端与视觉
|
||||
|
||||
- 只有前端任务需要先阅读根目录 `DESIGN.md`;`.impeccable/design.json` 是同源机器规范。
|
||||
@ -64,12 +73,14 @@
|
||||
|
||||
## 8. 构建与生产部署
|
||||
|
||||
- 发布脚本会拒绝覆盖“不在本次版本里”的线上提交(防止互相冲掉已上线的改动)。热修复必须基于线上当前版本构建,发布后立即合入 `main`;遇到拦截先 `git fetch` 并合入最新 `main` 或线上热修复分支,不得随手加 `-AllowRollback`(`--allow-rollback`),撤回线上发布需负责人明确决定。
|
||||
- 普通代码发布由任务 Codex 在完成验证与合并后直接执行,不要求用户再次说“部署、上线”;用户明确要求仅本地开发、仅审查或暂不上线时遵循其范围。正式发布只使用已合并的最新 `main`。
|
||||
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`,macOS 开发机使用 `bash scripts/deploy-from-mac.sh`;两者流程一致(本机构建前端与镜像、迁移拦截、增量推送服务器本机 Registry、激活与健康检查、双版本保留)。服务器只运行开发机已构建的 `caiwu_akaunting:<commit>` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。
|
||||
- 新人只需表达部署需求;Codex 使用默认生产入口运行脚本。首次通过本机窗口提供一次服务器密码后,加密保存在本人电脑供后续自动使用,不要求新人准备私钥、执行命令或每次重输密码。已有私钥认证仍兼容。
|
||||
- 当前生产入口为 `https://caiwu.jiansuntuzhi.cn:8442`;公网仍只使用 `8442`,日常发布不得改成无端口地址。
|
||||
- 发布脚本检测到新增数据库迁移时,由 Codex 核对全部待执行迁移。仅涉及本任务共享镜像建表、字段、索引和同步登记的迁移,完成备份后自主使用 `-AllowMigrations`(macOS 为 `--allow-migrations`);其他迁移仍按第 6 节确认授权。不能因需要迁移开关就把镜像接入停给负责人。
|
||||
- 容器内 Laravel 命令使用 `www-data`。发布后只需确认部署 SHA、应用容器健康、受影响的真实页面/接口、迁移是否执行,以及当前和上一版镜像可回滚。
|
||||
- 生产重型 CLI 导入统一使用 `sh /opt/caiwuxitong/run-finance-batch.sh <命令> <原参数>`,不得在网页容器内直接执行或取消 PHP 内存限制。新增导入登记 `deploy/finance-batch-imports.json`;入口提供独立容器、互斥、资源与时间上限,主应用保留自己的内存边界。
|
||||
- 程序静态资源随应用镜像同源发布;七牛只存用户上传的业务图片、票据和附件。
|
||||
- 只有本地初始化、服务器维护、生产发布或数据库迁移任务需要读取 `docs/开发与部署SOP.md` 的对应章节;普通功能开发无需加载整份 SOP。
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
||||
@ -30,12 +31,12 @@ class WithdrawalManagementReport
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string},
|
||||
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string},
|
||||
* platforms:list<string>,paths:list<string>,storeEntities:list<string>,rows:Collection<int,StoreReceivingAccount>,
|
||||
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
|
||||
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
|
||||
* summary:array{source:int,matched:int,ambiguous:int,unmatched:int,not_ready:int},
|
||||
* balanceColumnLabel:string,balanceDate:string,sort:array{field:string,direction:string}
|
||||
* balanceColumnLabel:string,balanceDate:string,balanceDates:list<string>,sort:array{field:string,direction:string}
|
||||
* }
|
||||
*/
|
||||
public function results(Request $request): array
|
||||
@ -61,12 +62,16 @@ class WithdrawalManagementReport
|
||||
}
|
||||
|
||||
$today = now()->startOfDay();
|
||||
// 余额快照按导入日期保留 5 个自然日:优先显示最近 5 天内导入的最新余额日期;
|
||||
// 当天尚未导入新数据时,继续沿用最近一次可用余额,避免页面直接变成待同步。
|
||||
$balanceDate = $this->shopBalances->latestDateInRetention($today)
|
||||
?? $today->copy()->subDay()->toDateString();
|
||||
// 余额快照按日期长期保留:默认显示最新余额日期,也可指定历史日期回看。
|
||||
$latestBalanceDate = $this->shopBalances->latestBalanceDate($today);
|
||||
$balanceDate = $filters['balanceDate'] !== ''
|
||||
? $filters['balanceDate']
|
||||
: ($latestBalanceDate ?? $today->copy()->subDay()->toDateString());
|
||||
$labelDate = $filters['balanceDate'] !== ''
|
||||
? CarbonImmutable::parse($balanceDate)
|
||||
: $today;
|
||||
$rows = $query->orderByDesc('id')->get();
|
||||
$balances = $this->shopBalances->matchesForStores($rows, $balanceDate, $today);
|
||||
$balances = $this->shopBalances->matchesForStores($rows, $balanceDate);
|
||||
$balanceCounts = collect($balances)->countBy(
|
||||
static fn (array $match): string => $match['status'],
|
||||
);
|
||||
@ -114,27 +119,34 @@ class WithdrawalManagementReport
|
||||
'withdrawalAmounts' => $withdrawalAmounts,
|
||||
'compliance' => $compliance,
|
||||
'summary' => [
|
||||
'source' => $this->shopBalances->countForDate($balanceDate, $today),
|
||||
'source' => $this->shopBalances->countForDate($balanceDate),
|
||||
'matched' => (int) $balanceCounts->get('matched', 0),
|
||||
'ambiguous' => (int) $balanceCounts->get('ambiguous', 0),
|
||||
'unmatched' => (int) $balanceCounts->get('unmatched', 0),
|
||||
'not_ready' => (int) $balanceCounts->get('not_ready', 0),
|
||||
],
|
||||
'balanceColumnLabel' => $today->format('n月j日') . '店铺余额',
|
||||
'balanceColumnLabel' => $labelDate->format('n月j日') . '店铺余额',
|
||||
'balanceDate' => $balanceDate,
|
||||
'balanceDates' => $this->shopBalances->availableDates($today),
|
||||
'sort' => $sort,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string} */
|
||||
/** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string} */
|
||||
public function filters(Request $request): array
|
||||
{
|
||||
$clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
|
||||
$balanceStatus = $clean($request->query('balance_status'));
|
||||
$balanceDate = $clean($request->query('balance_date'), 10);
|
||||
|
||||
if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) {
|
||||
$balanceStatus = '';
|
||||
}
|
||||
$validDate = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $balanceDate, $parts) === 1
|
||||
&& checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1]);
|
||||
if (! $validDate || $balanceDate > now()->toDateString()) {
|
||||
$balanceDate = '';
|
||||
}
|
||||
|
||||
return [
|
||||
'platform' => $clean($request->query('platform')),
|
||||
@ -142,6 +154,7 @@ class WithdrawalManagementReport
|
||||
'storeEntity' => $clean($request->query('store_entity'), 191),
|
||||
'keyword' => $clean($request->query('keyword'), 191),
|
||||
'balanceStatus' => $balanceStatus,
|
||||
'balanceDate' => $balanceDate,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -436,7 +436,7 @@ class WithdrawalManagementSpreadsheet
|
||||
{
|
||||
$today = now()->startOfDay();
|
||||
|
||||
return $this->shopBalances->latestDateInRetention($today)
|
||||
return $this->shopBalances->latestBalanceDate($today)
|
||||
?? $today->copy()->subDay()->toDateString();
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
326
app/Console/Commands/ImportAfterSalesCostRows.php
Normal file
326
app/Console/Commands/ImportAfterSalesCostRows.php
Normal file
@ -0,0 +1,326 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository;
|
||||
use XMLReader;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* 导入聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」Excel。
|
||||
*
|
||||
* 该报表已按商品编码(组合装拆成组件)给出每行和成本,导入后订单售后费用明细页面
|
||||
* 在覆盖的店铺/日期范围内直接按这些明细行展示。
|
||||
*/
|
||||
class ImportAfterSalesCostRows extends Command
|
||||
{
|
||||
protected $signature = 'order-data:import-after-sales-cost {file : Excel 文件路径} {--chunk=500 : 每批写入行数} {--dry-run : 只解析不入库}';
|
||||
|
||||
protected $description = '导入聚水潭「订单售后费用数据」Excel 到订单售后费用明细';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$path = (string) $this->argument('file');
|
||||
|
||||
if (! is_file($path)) {
|
||||
$this->error('文件不存在:' . $path);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$headers = (array) config('order_after_sales_cost.headers', []);
|
||||
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
|
||||
|
||||
if ($headers === []) {
|
||||
$this->error('缺少表头映射:config/order_after_sales_cost.php');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('流式读取文件:' . $path);
|
||||
|
||||
$sheetPath = $this->firstSheetPath($path);
|
||||
|
||||
if ($sheetPath === null) {
|
||||
$this->error('不是有效的 xlsx(缺少工作表)。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$sharedStrings = $this->readSharedStrings($path);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error('读取共享字符串失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
$first = true;
|
||||
$chunkSize = max((int) $this->option('chunk'), 100);
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$buffer = [];
|
||||
$total = 0;
|
||||
$written = 0;
|
||||
$skipped = 0;
|
||||
$shops = [];
|
||||
$minDate = null;
|
||||
$maxDate = null;
|
||||
$importedAt = now()->toDateTimeString();
|
||||
|
||||
$reader = new XMLReader();
|
||||
|
||||
if (! $reader->open('zip://' . $path . '#' . $sheetPath)) {
|
||||
$this->error('无法打开工作表:' . $sheetPath);
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
while ($reader->read()) {
|
||||
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cells = $this->readRow($reader, $sharedStrings);
|
||||
|
||||
if ($first) {
|
||||
$first = false;
|
||||
|
||||
foreach ($cells as $index => $header) {
|
||||
$header = self::normalizeHeader((string) $header);
|
||||
|
||||
if ($header !== '' && isset($headers[$header])) {
|
||||
$columns[$index] = $headers[$header];
|
||||
}
|
||||
}
|
||||
|
||||
if ($columns === []) {
|
||||
$reader->close();
|
||||
$this->error('表头和配置的映射对不上,请检查文件或 config/order_after_sales_cost.php');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$total++;
|
||||
$payload = [];
|
||||
|
||||
foreach ($columns as $index => $key) {
|
||||
$value = $cells[$index] ?? null;
|
||||
|
||||
if (is_string($value)) {
|
||||
$value = trim($value);
|
||||
}
|
||||
|
||||
if ($value === '' || $value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($numberKeys[$key])) {
|
||||
$value = is_numeric($value) ? (float) $value : 0.0;
|
||||
} elseif (self::isDateKey($key)) {
|
||||
$value = self::dateValue($value);
|
||||
}
|
||||
|
||||
$payload[$key] = $value;
|
||||
}
|
||||
|
||||
$shop = trim((string) ($payload['shop_name'] ?? ''));
|
||||
|
||||
if ($shop === '' || empty($payload['product_code'])) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$buffer[] = AfterSalesCostRowRepository::toRecord($payload, $importedAt);
|
||||
$shops[$shop] = true;
|
||||
$orderTime = $payload['order_time'] ?? null;
|
||||
|
||||
if (is_string($orderTime) && $orderTime !== '') {
|
||||
$minDate = $minDate === null || $orderTime < $minDate ? $orderTime : $minDate;
|
||||
$maxDate = $maxDate === null || $orderTime > $maxDate ? $orderTime : $maxDate;
|
||||
}
|
||||
|
||||
if (! $dryRun && count($buffer) >= $chunkSize) {
|
||||
$written += $this->flush($buffer);
|
||||
$buffer = [];
|
||||
$this->output->write("\r写入 " . $written . ' 行');
|
||||
}
|
||||
|
||||
if ($dryRun && $buffer !== []) {
|
||||
$buffer = [];
|
||||
}
|
||||
}
|
||||
|
||||
$reader->close();
|
||||
|
||||
if (! $dryRun && $buffer !== []) {
|
||||
$written += $this->flush($buffer);
|
||||
}
|
||||
|
||||
$this->newLine(2);
|
||||
$this->info(sprintf(
|
||||
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;订单时间 %s ~ %s;峰值内存 %.1f MB',
|
||||
$total,
|
||||
$written,
|
||||
$skipped,
|
||||
count($shops),
|
||||
$minDate ?? '-',
|
||||
$maxDate ?? '-',
|
||||
memory_get_peak_usage(true) / 1048576,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function firstSheetPath(string $path): ?string
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($path) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sheet = null;
|
||||
|
||||
if ($zip->locateName('xl/worksheets/sheet1.xml') !== false) {
|
||||
$sheet = 'xl/worksheets/sheet1.xml';
|
||||
} else {
|
||||
for ($i = 1; $i <= 30; $i++) {
|
||||
$name = 'xl/worksheets/sheet' . $i . '.xml';
|
||||
|
||||
if ($zip->locateName($name) !== false) {
|
||||
$sheet = $name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $sheet;
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
private function readSharedStrings(string $path): array
|
||||
{
|
||||
$strings = [];
|
||||
$reader = new XMLReader();
|
||||
|
||||
if (! $reader->open('zip://' . $path . '#xl/sharedStrings.xml')) {
|
||||
return $strings;
|
||||
}
|
||||
|
||||
while ($reader->read()) {
|
||||
if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'si') {
|
||||
$xml = $reader->readOuterXml();
|
||||
$strings[] = html_entity_decode(strip_tags($xml), ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
$reader->close();
|
||||
|
||||
return $strings;
|
||||
}
|
||||
|
||||
/** @param array<int,string> $shared @return array<int,mixed> */
|
||||
private function readRow(XMLReader $reader, array $shared): array
|
||||
{
|
||||
$cells = [];
|
||||
$xml = $reader->readOuterXml();
|
||||
|
||||
if ($xml === '') {
|
||||
return $cells;
|
||||
}
|
||||
|
||||
$row = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
|
||||
|
||||
if ($row === false) {
|
||||
return $cells;
|
||||
}
|
||||
|
||||
foreach ($row->c as $cell) {
|
||||
$index = self::columnIndex((string) $cell['r']);
|
||||
$type = (string) $cell['t'];
|
||||
|
||||
if ($type === 's') {
|
||||
$value = $shared[(int) $cell->v] ?? '';
|
||||
} elseif ($type === 'inlineStr') {
|
||||
$value = (string) $cell->is->t;
|
||||
} else {
|
||||
$value = (string) $cell->v;
|
||||
}
|
||||
|
||||
$cells[$index] = $value;
|
||||
}
|
||||
|
||||
return $cells;
|
||||
}
|
||||
|
||||
private static function columnIndex(string $reference): int
|
||||
{
|
||||
$letters = preg_replace('/[^A-Z]/', '', strtoupper($reference)) ?? '';
|
||||
$index = 0;
|
||||
|
||||
foreach (str_split($letters) as $letter) {
|
||||
$index = $index * 26 + (ord($letter) - 64);
|
||||
}
|
||||
|
||||
return $index - 1;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows */
|
||||
private function flush(array $rows): int
|
||||
{
|
||||
DB::table('ops_after_sales_cost_rows')->upsert(
|
||||
$rows,
|
||||
['row_key'],
|
||||
[
|
||||
'shop_name', 'shop_code', 'online_order_no', 'internal_order_no', 'product_code',
|
||||
'order_time', 'product_quantity', 'product_amount', 'product_cost', 'cost_source',
|
||||
'combine_type', 'payload', 'imported_at', 'updated_at',
|
||||
],
|
||||
);
|
||||
|
||||
return count($rows);
|
||||
}
|
||||
|
||||
private static function normalizeHeader(string $header): string
|
||||
{
|
||||
$header = str_replace(["\u{3000}", "\u{00A0}"], ' ', $header);
|
||||
$header = preg_replace('/\s+/u', ' ', $header) ?? $header;
|
||||
|
||||
return trim($header);
|
||||
}
|
||||
|
||||
private static function isDateKey(string $key): bool
|
||||
{
|
||||
return in_array($key, [
|
||||
'pay_time', 'order_complete_time', 'send_time', 'order_time', 'registration_time',
|
||||
'confirmation_time', 'warehouse_entry_time',
|
||||
], true);
|
||||
}
|
||||
|
||||
private static function dateValue(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 80000) {
|
||||
// Excel 序列日期(1899-12-30 起算),按本地时间格式化。
|
||||
$timestamp = ((float) $value - 25569) * 86400;
|
||||
|
||||
return gmdate('Y-m-d H:i:s', (int) $timestamp);
|
||||
}
|
||||
|
||||
$timestamp = strtotime((string) $value);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -32,12 +32,11 @@ class ImportJushuitanShopBalances extends Command
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'%s:%s 共 %d 条,替换原有 %d 条,清理 5 天前 %d 条。',
|
||||
'%s:%s 共 %d 条,替换原有 %d 条。',
|
||||
$result['dry_run'] ? '校验完成' : '导入完成',
|
||||
$result['balance_date'],
|
||||
$result['total'],
|
||||
$result['replaced'],
|
||||
$result['pruned'],
|
||||
));
|
||||
|
||||
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())];
|
||||
}
|
||||
}
|
||||
60
app/Console/Commands/SyncFinanceLedgerRpt.php
Normal file
60
app/Console/Commands/SyncFinanceLedgerRpt.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerRptSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncFinanceLedgerRpt extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:sync-rpt
|
||||
{--from= : 业务日期起,默认读取配置}
|
||||
{--to= : 业务日期止,默认读取配置或当天}
|
||||
{--days= : 只拉取最近 N 天并合并到现有明细}
|
||||
{--merge : 合并到现有明细,不删除历史}
|
||||
{--no-recalculate : 只同步明细,不重算流水}';
|
||||
|
||||
protected $description = '从 AI 库 rpt 同步财务台账动账明细,并重算每日流水';
|
||||
|
||||
public function handle(FinanceLedgerRptSynchronizer $synchronizer): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?: 0);
|
||||
$from = $this->option('from') ?: null;
|
||||
$merge = (bool) $this->option('merge');
|
||||
if ($days > 0) {
|
||||
$from = now()->subDays(max(1, $days) - 1)->toDateString();
|
||||
$merge = true;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $synchronizer->sync(
|
||||
! $this->option('no-recalculate'),
|
||||
$from,
|
||||
$this->option('to') ?: null,
|
||||
$merge,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['明细行数', $result['rows']],
|
||||
['收入合计', number_format($result['income'], 2)],
|
||||
['支出合计', number_format($result['expense'], 2)],
|
||||
['日期范围', $result['from'] . ' ~ ' . $result['to']],
|
||||
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
|
||||
['期末余额合计', isset($result['recalculated']) ? number_format($result['recalculated']['balance_sum'], 2) : '-'],
|
||||
]);
|
||||
|
||||
$this->line('来源分布:');
|
||||
foreach (($result['source_counts'] ?? []) as $source => $count) {
|
||||
$this->line(' ' . $source . ': ' . $count);
|
||||
}
|
||||
$this->info('财务台账 rpt 同步完成。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -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,19 @@ class SyncStoreReceiptProductCost extends Command
|
||||
/** 虚拟条码(信誉订单/虚拟订单)不是真实商品,不计入商品成本。 */
|
||||
private const VIRTUAL_SKU_PREFIX = 'X0000';
|
||||
|
||||
/** 套餐 / 服务占位编码:不是实物商品,商品成本不取数。 */
|
||||
private const EXCLUDED_SKUS = ['TCXJ', 'FUWU'];
|
||||
|
||||
/**
|
||||
* 发货仓库黑名单:这些仓不是实际销售发货仓,整仓不参与商品成本;
|
||||
* 例外:常州简尚家居有限公司里商品编码以 DF- 开头的行保留。
|
||||
*/
|
||||
private const EXCLUDED_WAREHOUSES = [
|
||||
'常州简尚家居有限公司',
|
||||
'简尚-苏州福利品仓',
|
||||
'简尚-自动发货仓',
|
||||
];
|
||||
|
||||
private const REMARK_NO_WAREHOUSE = '未匹配仓库简称';
|
||||
|
||||
private const REMARK_NO_PRICE = '未匹配商品单价';
|
||||
@ -54,6 +67,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 +130,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 +187,25 @@ 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})
|
||||
AND UPPER(TRIM(COALESCE(o.status, ''))) <> 'CANCELLED'"
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('订单明细查询失败(' . $source['items'] . '):' . $exception->getMessage());
|
||||
@ -210,13 +236,34 @@ class SyncStoreReceiptProductCost extends Command
|
||||
$warehouse = '';
|
||||
}
|
||||
}
|
||||
$lines[$core][] = [
|
||||
// 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。
|
||||
'sku' => mb_strtoupper($sku),
|
||||
'qty' => $qty,
|
||||
'amount' => $amount,
|
||||
'warehouse' => $warehouse,
|
||||
];
|
||||
|
||||
// 发货仓库过滤(在 TCXJ/FU WU 之后执行):
|
||||
// 默认排除常州简尚家居有限公司 / 简尚-苏州福利品仓 / 简尚-自动发货仓;
|
||||
// 例外:常州简尚家居有限公司里「DF-」开头的商品行保留。
|
||||
if (in_array($warehouse, self::EXCLUDED_WAREHOUSES, true)) {
|
||||
$isDfException = $warehouse === '常州简尚家居有限公司'
|
||||
&& str_starts_with(mb_strtoupper($sku), 'DF-');
|
||||
if (! $isDfException) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 组合装条码(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 +272,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 +293,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 +310,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 +389,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 +401,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 +418,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 +452,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 +511,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 +519,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 +651,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 --days=7 --merge')
|
||||
->dailyAt('02:30')
|
||||
->timezone('Asia/Shanghai')
|
||||
->withoutOverlapping(120)
|
||||
->runInBackground();
|
||||
|
||||
$schedule->command('business-mirror:sync-third-party-warehouse-storage --max-batches=0')
|
||||
->dailyAt('00:00')
|
||||
->withoutOverlapping(180)
|
||||
@ -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
|
||||
|
||||
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Abstracts\Http\Controller;
|
||||
use App\Http\Requests\Auth\Login as Request;
|
||||
use App\Models\Common\Contact;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
@ -79,10 +80,14 @@ class Login extends Controller
|
||||
// Redirect to portal if is customer
|
||||
if ($user->isCustomer()) {
|
||||
$path = session('url.intended', '');
|
||||
$portalHome = $user->contact instanceof Contact
|
||||
&& $user->contact->type === Contact::VENDOR_TYPE
|
||||
? route('portal.supplier-reconciliation.index', ['company_id' => $company->id])
|
||||
: route('portal.dashboard', ['company_id' => $company->id]);
|
||||
|
||||
// Path must start with company id and 'portal' prefix
|
||||
if (!Str::startsWith($path, $company->id . '/portal')) {
|
||||
$path = route('portal.dashboard', ['company_id' => $company->id]);
|
||||
$path = $portalHome;
|
||||
}
|
||||
|
||||
if (! $request->expectsJson()) {
|
||||
@ -102,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([
|
||||
@ -112,7 +120,7 @@ class Login extends Controller
|
||||
'error' => false,
|
||||
'message' => trans('auth.login_redirect'),
|
||||
'data' => null,
|
||||
'redirect' => redirect()->intended($url)->getTargetUrl(),
|
||||
'redirect' => url($url),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ class AccessoryReshipDeductions extends Controller
|
||||
fwrite($file, "\xEF\xBB\xBF");
|
||||
fputcsv($file, ['交易单号','补发快递单号','补发快递','补发时间','原始条形码','处理后条形码','补发运费','匹配状态','首发快递公司(候选)','首发快递单号(候选)','首发所属月份(候选)','扣款比例(%)','扣款金额','说明']);
|
||||
foreach ($data->rows($filters) as $row) {
|
||||
if ($filters['match_status'] !== 'all' && $row['status'] !== $filters['match_status']) continue;
|
||||
if (!AccessoryReshipPageData::inCategory($row, $filters['match_status'])) continue;
|
||||
$values = [$row['trade_no'], $row['express_no'], ($row['express_name1'] ?: ($row['express_name2'] ?? '')), $row['ship_time'], $row['barcode'], $row['normalized_barcode'], $row['freight'], $row['deduction_state'] === 'ready' ? '可扣款' : ($row['status'] === 'matched' ? '已匹配待核对' : '未匹配不扣款'), implode(' / ', array_column($row['matches'], 'courier')), implode(' / ', array_column($row['matches'], 'express_no')), implode(' / ', array_column($row['matches'], 'month')), $row['deduction_rate_basis_points'] === null ? '' : number_format($row['deduction_rate_basis_points'] / 100, 2, '.', '') . '%', $row['deduction_cents'] === null ? '' : number_format($row['deduction_cents'] / 100, 2, '.', ''), $row['deduction_note']];
|
||||
fputcsv($file, array_map(static function ($value) {
|
||||
$value = (string) $value;
|
||||
|
||||
@ -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 直接可用的字段(变更内容与操作人只在服务端算一次)。
|
||||
*
|
||||
|
||||
@ -165,6 +165,41 @@ class CourierQuoteChanges extends Controller
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
public function batchApprove(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
|
||||
$input = $request->validate([
|
||||
'ids' => ['required', 'array', 'min:1', 'max:100'],
|
||||
'ids.*' => ['required', 'integer', 'min:1', 'distinct'],
|
||||
'stock_rules_confirmed' => ['accepted'],
|
||||
], ['stock_rules_confirmed.accepted' => '请先核对选中报价的原报价、计费规则及实际执行日期。']);
|
||||
$results = [];
|
||||
// 历史签署资料需要逐份核对,不接受批量标记已经核验。
|
||||
$singleRequest = $request->duplicate();
|
||||
$singleRequest->merge(['stock_signature_verified' => false]);
|
||||
foreach ($input['ids'] as $id) {
|
||||
$change = CourierQuoteChange::query()->where('company_id', company_id())->find($id);
|
||||
if ($change === null) {
|
||||
$results[] = ['number' => '所选记录', 'success' => false, 'message' => '记录已不存在或不属于当前公司。'];
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// 复用单条入口,权限、校验、版本、日志及后续签署流程保持一致。
|
||||
$response = $this->approve($singleRequest, $change);
|
||||
$results[] = ['number' => $change->number, 'success' => true, 'message' => $response->getSession()->get('success')];
|
||||
} catch (\Illuminate\Validation\ValidationException $exception) {
|
||||
$results[] = ['number' => $change->number, 'success' => false, 'message' => collect($exception->errors())->flatten()->implode(';')];
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
$results[] = ['number' => $change->number, 'success' => false, 'message' => '处理失败,请查看该单状态并重试。'];
|
||||
}
|
||||
}
|
||||
$passed = count(array_filter($results, static fn ($result) => $result['success']));
|
||||
return redirect()->route('business.courier-quote-changes.index', ['page' => max(1, (int) $request->input('page', 1))])
|
||||
->with('success', '批量审核完成:成功 ' . $passed . ' 条,未通过 ' . (count($results) - $passed) . ' 条。')
|
||||
->with('batch_review_results', $results);
|
||||
}
|
||||
|
||||
/** 已入驻快递的变价版本按入驻资料(重新)推送到快递 e签宝。 */
|
||||
public function pushToCourier(CourierQuoteChange $quoteChange): RedirectResponse
|
||||
{
|
||||
|
||||
@ -38,6 +38,7 @@ class CourierStockQuoteEntries extends Controller
|
||||
'source_quote' => ['required', 'file', 'mimes:xlsx,xls', 'max:20480'],
|
||||
'quote_type' => ['required', 'string', 'max:64'],
|
||||
'effective_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'billing_rules' => ['nullable', 'string', 'max:4000'],
|
||||
]);
|
||||
$batch = $this->batches->preview($data['source_quote'], collect($data)->except('source_quote')->all(), (int) user_id());
|
||||
@ -77,6 +78,7 @@ class CourierStockQuoteEntries extends Controller
|
||||
$data = Validator::make($selection, [
|
||||
'quote_type' => ['required', 'string', 'max:64'],
|
||||
'effective_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'summary' => ['required', 'string', 'min:2', 'max:2000'],
|
||||
'rule_groups' => ['required', 'array', 'min:1', 'max:10'],
|
||||
'rule_groups.*' => ['array'],
|
||||
@ -89,6 +91,7 @@ class CourierStockQuoteEntries extends Controller
|
||||
'entries.*.courier_name' => ['nullable', 'string', 'max:255'],
|
||||
'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'],
|
||||
'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'entries.*.end_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'submit_for_review' => ['required', 'boolean'],
|
||||
'confirmed' => ['accepted_if:submit_for_review,true'],
|
||||
])->validate();
|
||||
@ -166,7 +169,7 @@ class CourierStockQuoteEntries extends Controller
|
||||
$headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'];
|
||||
if ($preview) {
|
||||
$mime = $attachment['mime_type'] ?? $disk->mimeType($attachment['path']);
|
||||
abort_unless(in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true), 415, '此格式请下载原文件查看。');
|
||||
abort_unless(in_array($mime, ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true), 415, '此格式请下载原文件查看。');
|
||||
|
||||
return $disk->response($attachment['path'], $attachment['name'], [...$headers, 'Content-Type' => $mime]);
|
||||
}
|
||||
@ -178,6 +181,7 @@ class CourierStockQuoteEntries extends Controller
|
||||
{
|
||||
return [
|
||||
'effective_date' => ['required', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
|
||||
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
|
||||
'summary' => ['required', 'string', 'min:2', 'max:2000'],
|
||||
];
|
||||
@ -198,4 +202,13 @@ class CourierStockQuoteEntries extends Controller
|
||||
{
|
||||
abort_unless($entry->company_id === (int) company_id() && $entry->change?->isEditable() && $entry->change->created_by === user_id(), 403);
|
||||
}
|
||||
|
||||
public function periodEnd(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse
|
||||
{
|
||||
abort_unless(in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::SUPER_ADMIN, \App\Utilities\SystemAccess::ADMIN], true), 403);
|
||||
abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404);
|
||||
$data = $request->validate(['end_date' => ['required', 'date_format:Y-m-d'], 'reason' => ['required', 'string', 'min:2', 'max:1000']]);
|
||||
$this->service->setPeriodEnd($stockQuoteEntry, $data['end_date'], $data['reason'], (int) user_id());
|
||||
return redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management'])->with('success', '执行截止日期已保存,报价原文和签署原件保持不变。');
|
||||
}
|
||||
}
|
||||
|
||||
810
app/Http/Controllers/Business/FinanceLedger.php
Normal file
810
app/Http/Controllers/Business/FinanceLedger.php
Normal file
@ -0,0 +1,810 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Utilities\FinanceLedgerAccountStatus;
|
||||
use App\Utilities\FinanceLedgerAccountShouldKeep;
|
||||
use App\Utilities\FinanceLedgerAccountType;
|
||||
use App\Utilities\FinanceLedgerAccess;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class FinanceLedger extends Controller
|
||||
{
|
||||
public function downloadAccountTemplate(FinanceLedgerAccountTemplate $template): StreamedResponse
|
||||
{
|
||||
return $template->download();
|
||||
}
|
||||
|
||||
public function unavailableBalanceTasks(Request $request)
|
||||
{
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$abnormalStatuses = array_values(array_diff(
|
||||
array_keys(FinanceLedgerAccountStatus::OPTIONS),
|
||||
[FinanceLedgerAccountStatus::NORMAL],
|
||||
));
|
||||
$handlingMethods = [
|
||||
'online' => '线上',
|
||||
'offline' => '线下',
|
||||
];
|
||||
$completionStatuses = [
|
||||
'completed' => '已完成',
|
||||
'in_progress' => '进行中',
|
||||
'not_started' => '待开始',
|
||||
'incomplete' => '未完成',
|
||||
];
|
||||
$filters = [
|
||||
'pending_short' => trim((string) $request->query('pending_short', '')),
|
||||
'pending_type' => trim((string) $request->query('pending_type', '')),
|
||||
'pending_status' => trim((string) $request->query('pending_status', '')),
|
||||
'pending_reason' => trim((string) $request->query('pending_reason', '')),
|
||||
'pending_method' => trim((string) $request->query('pending_method', '')),
|
||||
'pending_owner' => trim((string) $request->query('pending_owner', '')),
|
||||
];
|
||||
|
||||
$query = $connection->table('fin_accounts as accounts')
|
||||
->join('fin_account_statuses as account_status', 'account_status.account_short', '=', 'accounts.account_short')
|
||||
->leftJoin('fin_account_type_overrides as type_override', 'type_override.account_short', '=', 'accounts.account_short')
|
||||
->leftJoin('fin_account_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short')
|
||||
->whereIn('account_status.status', $abnormalStatuses);
|
||||
|
||||
if ($filters['pending_short'] !== '') {
|
||||
$query->where('accounts.account_short', 'like', '%' . $filters['pending_short'] . '%');
|
||||
}
|
||||
if ($filters['pending_type'] !== '') {
|
||||
$query->where(function ($effectiveType) use ($filters): void {
|
||||
$effectiveType->where(function ($baseType) use ($filters): void {
|
||||
$baseType->whereNull('type_override.id');
|
||||
if ($filters['pending_type'] === '__empty__') {
|
||||
$baseType->where(function ($blank): void {
|
||||
$blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', '');
|
||||
});
|
||||
} else {
|
||||
$baseType->where('accounts.account_type', $filters['pending_type']);
|
||||
}
|
||||
})->orWhere(function ($overrideType) use ($filters): void {
|
||||
$overrideType->whereNotNull('type_override.id');
|
||||
if ($filters['pending_type'] === '__empty__') {
|
||||
$overrideType->where(function ($blank): void {
|
||||
$blank->whereNull('type_override.account_type')->orWhere('type_override.account_type', '');
|
||||
});
|
||||
} else {
|
||||
$overrideType->where('type_override.account_type', $filters['pending_type']);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if ($filters['pending_status'] !== '' && array_key_exists($filters['pending_status'], FinanceLedgerAccountStatus::OPTIONS)) {
|
||||
$query->where('account_status.status', $filters['pending_status']);
|
||||
}
|
||||
if ($filters['pending_reason'] !== '') {
|
||||
$query->where('account_todo.abnormal_reason', 'like', '%' . $filters['pending_reason'] . '%');
|
||||
}
|
||||
if ($filters['pending_method'] !== '' && array_key_exists($filters['pending_method'], $handlingMethods)) {
|
||||
$query->where('account_todo.handling_method', $filters['pending_method']);
|
||||
}
|
||||
if ($filters['pending_owner'] !== '') {
|
||||
$query->where('account_todo.owner_name', 'like', '%' . $filters['pending_owner'] . '%');
|
||||
}
|
||||
|
||||
$summaryQuery = clone $query;
|
||||
$summary = [
|
||||
'count' => (clone $query)->count(),
|
||||
'current_balance' => round((float) (clone $summaryQuery)->sum('accounts.current_balance'), 2),
|
||||
'balance' => round((float) (clone $summaryQuery)->sum('accounts.daily_available'), 2),
|
||||
'blocked' => round((float) (clone $summaryQuery)->sum('accounts.blocked_amount'), 2),
|
||||
'daily_available' => round((float) (clone $summaryQuery)->sum('accounts.daily_available'), 2),
|
||||
];
|
||||
|
||||
$sortField = (string) $request->query('pending_sort', '');
|
||||
$sortField = in_array($sortField, ['started_at', 'completed_at'], true) ? $sortField : '';
|
||||
$sortDirection = strtolower((string) $request->query('pending_direction', 'asc'));
|
||||
$sortDirection = $sortDirection === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$query->select([
|
||||
'accounts.*',
|
||||
'account_status.status as effective_account_status',
|
||||
'type_override.id as type_override_id',
|
||||
'type_override.account_type as effective_account_type',
|
||||
'account_todo.abnormal_reason as todo_abnormal_reason',
|
||||
'account_todo.handling_method as todo_handling_method',
|
||||
'account_todo.owner_name as todo_owner_name',
|
||||
'account_todo.started_at as todo_started_at',
|
||||
'account_todo.completed_at as todo_completed_at',
|
||||
'account_todo.current_progress as todo_current_progress',
|
||||
'account_todo.completion_status as todo_completion_status',
|
||||
]);
|
||||
|
||||
if ($sortField !== '') {
|
||||
$query->orderBy('account_todo.' . $sortField, $sortDirection);
|
||||
} else {
|
||||
$query->orderByDesc('accounts.blocked_amount');
|
||||
}
|
||||
$rows = $query
|
||||
->orderBy('accounts.company_name')
|
||||
->orderBy('accounts.account_short')
|
||||
->paginate(50)
|
||||
->appends($filters)
|
||||
->appends(array_filter([
|
||||
'pending_sort' => $sortField,
|
||||
'pending_direction' => $sortField !== '' ? $sortDirection : '',
|
||||
]))
|
||||
->withQueryString();
|
||||
|
||||
$rows->getCollection()->transform(static function ($row) {
|
||||
$row->account_status = FinanceLedgerAccountStatus::normalize($row->effective_account_status ?? null);
|
||||
if ($row->type_override_id !== null) {
|
||||
$row->account_type = (string) ($row->effective_account_type ?? '');
|
||||
}
|
||||
$row->todo_completion_status = $row->todo_completion_status ?: 'not_started';
|
||||
|
||||
return $row;
|
||||
});
|
||||
|
||||
$typeOptions = FinanceLedgerAccountType::options(
|
||||
$connection->table('fin_accounts')
|
||||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||||
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
|
||||
$connection->table('fin_account_type_overrides')
|
||||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||||
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
|
||||
);
|
||||
$reasonOptions = $connection->table('fin_account_todos')
|
||||
->whereNotNull('abnormal_reason')->where('abnormal_reason', '<>', '')
|
||||
->distinct()->orderBy('abnormal_reason')->pluck('abnormal_reason')->all();
|
||||
$ownerOptions = $connection->table('fin_account_todos')
|
||||
->whereNotNull('owner_name')->where('owner_name', '<>', '')
|
||||
->distinct()->orderBy('owner_name')->pluck('owner_name')->all();
|
||||
|
||||
return view('business.pages.financial-ledger.unavailable-balance-tasks', [
|
||||
'rows' => $rows,
|
||||
'summary' => $summary,
|
||||
'filters' => $filters,
|
||||
'sortField' => $sortField,
|
||||
'sortDirection' => $sortDirection,
|
||||
'backUrl' => route('business.pages.show', [
|
||||
'section' => 'financial-operations',
|
||||
'page' => 'ledger',
|
||||
]) . '?tab=accounts',
|
||||
'canEdit' => FinanceLedgerAccess::canEditAccountStatus(user()),
|
||||
'handlingMethods' => $handlingMethods,
|
||||
'completionStatuses' => $completionStatuses,
|
||||
'accountStatusOptions' => array_intersect_key(
|
||||
FinanceLedgerAccountStatus::OPTIONS,
|
||||
array_flip($abnormalStatuses),
|
||||
),
|
||||
'accountTypeOptions' => $typeOptions,
|
||||
'reasonOptions' => $reasonOptions,
|
||||
'ownerOptions' => $ownerOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function unretainedAccountTasks(Request $request)
|
||||
{
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$handlingMethods = [
|
||||
'online' => '线上',
|
||||
'offline' => '线下',
|
||||
];
|
||||
$completionStatuses = [
|
||||
'completed' => '已完成',
|
||||
'in_progress' => '进行中',
|
||||
'not_started' => '待开始',
|
||||
];
|
||||
$filters = [
|
||||
'keep_short' => trim((string) $request->query('keep_short', '')),
|
||||
'keep_type' => trim((string) $request->query('keep_type', '')),
|
||||
'keep_status' => trim((string) $request->query('keep_status', '')),
|
||||
'keep_method' => trim((string) $request->query('keep_method', '')),
|
||||
'keep_owner' => trim((string) $request->query('keep_owner', '')),
|
||||
'keep_progress' => trim((string) $request->query('keep_progress', '')),
|
||||
];
|
||||
|
||||
$query = $connection->table('fin_accounts as accounts')
|
||||
->leftJoin('fin_account_statuses as account_status', 'account_status.account_short', '=', 'accounts.account_short')
|
||||
->leftJoin('fin_account_type_overrides as type_override', 'type_override.account_short', '=', 'accounts.account_short')
|
||||
->leftJoin('fin_account_keep_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short')
|
||||
->select([
|
||||
'accounts.*',
|
||||
'account_status.status as effective_account_status',
|
||||
'type_override.id as type_override_id',
|
||||
'type_override.account_type as effective_account_type',
|
||||
'account_todo.handling_method as todo_handling_method',
|
||||
'account_todo.owner_name as todo_owner_name',
|
||||
'account_todo.started_at as todo_started_at',
|
||||
'account_todo.completed_at as todo_completed_at',
|
||||
'account_todo.current_progress as todo_current_progress',
|
||||
]);
|
||||
FinanceLedgerAccountShouldKeep::applyUnretainedCondition(
|
||||
$query,
|
||||
'accounts.account_should_keep',
|
||||
'accounts.company_plan',
|
||||
);
|
||||
|
||||
if ($filters['keep_short'] !== '') {
|
||||
$query->where('accounts.account_short', 'like', '%' . $filters['keep_short'] . '%');
|
||||
}
|
||||
if ($filters['keep_type'] !== '') {
|
||||
$query->where(function ($effectiveType) use ($filters): void {
|
||||
$effectiveType->where(function ($baseType) use ($filters): void {
|
||||
$baseType->whereNull('type_override.id');
|
||||
if ($filters['keep_type'] === '__empty__') {
|
||||
$baseType->where(function ($blank): void {
|
||||
$blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', '');
|
||||
});
|
||||
} else {
|
||||
$baseType->where('accounts.account_type', $filters['keep_type']);
|
||||
}
|
||||
})->orWhere(function ($overrideType) use ($filters): void {
|
||||
$overrideType->whereNotNull('type_override.id');
|
||||
if ($filters['keep_type'] === '__empty__') {
|
||||
$overrideType->where(function ($blank): void {
|
||||
$blank->whereNull('type_override.account_type')->orWhere('type_override.account_type', '');
|
||||
});
|
||||
} else {
|
||||
$overrideType->where('type_override.account_type', $filters['keep_type']);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if ($filters['keep_status'] !== '' && array_key_exists($filters['keep_status'], FinanceLedgerAccountStatus::OPTIONS)) {
|
||||
if ($filters['keep_status'] === FinanceLedgerAccountStatus::NORMAL) {
|
||||
$query->where(function ($normalStatus): void {
|
||||
$normalStatus->whereNull('account_status.status')
|
||||
->orWhere('account_status.status', FinanceLedgerAccountStatus::NORMAL);
|
||||
});
|
||||
} else {
|
||||
$query->where('account_status.status', $filters['keep_status']);
|
||||
}
|
||||
}
|
||||
if ($filters['keep_method'] !== '' && array_key_exists($filters['keep_method'], $handlingMethods)) {
|
||||
$query->where('account_todo.handling_method', $filters['keep_method']);
|
||||
}
|
||||
if ($filters['keep_owner'] !== '') {
|
||||
$query->where('account_todo.owner_name', 'like', '%' . $filters['keep_owner'] . '%');
|
||||
}
|
||||
if ($filters['keep_progress'] !== '' && array_key_exists($filters['keep_progress'], $completionStatuses)) {
|
||||
if ($filters['keep_progress'] === 'not_started') {
|
||||
$query->where(function ($notStarted): void {
|
||||
$notStarted->whereNull('account_todo.current_progress')
|
||||
->orWhere('account_todo.current_progress', 'not_started');
|
||||
});
|
||||
} else {
|
||||
$query->where('account_todo.current_progress', $filters['keep_progress']);
|
||||
}
|
||||
}
|
||||
|
||||
$summaryQuery = clone $query;
|
||||
$summary = [
|
||||
'count' => (clone $query)->count(),
|
||||
'in_progress' => (clone $summaryQuery)->where('account_todo.current_progress', 'in_progress')->count(),
|
||||
'not_started' => (clone $summaryQuery)->where(function ($notStarted): void {
|
||||
$notStarted->whereNull('account_todo.current_progress')
|
||||
->orWhere('account_todo.current_progress', 'not_started');
|
||||
})->count(),
|
||||
'completed' => (clone $summaryQuery)->where('account_todo.current_progress', 'completed')->count(),
|
||||
];
|
||||
|
||||
$sortField = (string) $request->query('keep_sort', '');
|
||||
$sortField = in_array($sortField, ['started_at', 'completed_at'], true) ? $sortField : '';
|
||||
$sortDirection = strtolower((string) $request->query('keep_direction', 'asc'));
|
||||
$sortDirection = $sortDirection === 'desc' ? 'desc' : 'asc';
|
||||
if ($sortField !== '') {
|
||||
$query->orderBy('account_todo.' . $sortField, $sortDirection);
|
||||
}
|
||||
|
||||
$rows = $query
|
||||
->orderBy('accounts.company_name')
|
||||
->orderBy('accounts.account_short')
|
||||
->paginate(50)
|
||||
->appends($filters)
|
||||
->appends(array_filter([
|
||||
'keep_sort' => $sortField,
|
||||
'keep_direction' => $sortField !== '' ? $sortDirection : '',
|
||||
]))
|
||||
->withQueryString();
|
||||
|
||||
$rows->getCollection()->transform(static function ($row) {
|
||||
$row->account_status = FinanceLedgerAccountStatus::normalize($row->effective_account_status ?? null);
|
||||
if ($row->type_override_id !== null) {
|
||||
$row->account_type = (string) ($row->effective_account_type ?? '');
|
||||
}
|
||||
$row->todo_current_progress = $row->todo_current_progress ?: 'not_started';
|
||||
|
||||
return $row;
|
||||
});
|
||||
|
||||
$typeOptions = FinanceLedgerAccountType::options(
|
||||
$connection->table('fin_accounts')
|
||||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||||
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
|
||||
$connection->table('fin_account_type_overrides')
|
||||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||||
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
|
||||
);
|
||||
$ownerOptions = $connection->table('fin_account_keep_todos')
|
||||
->whereNotNull('owner_name')->where('owner_name', '<>', '')
|
||||
->distinct()->orderBy('owner_name')->pluck('owner_name')->all();
|
||||
|
||||
return view('business.pages.financial-ledger.unretained-account-tasks', [
|
||||
'rows' => $rows,
|
||||
'summary' => $summary,
|
||||
'filters' => $filters,
|
||||
'sortField' => $sortField,
|
||||
'sortDirection' => $sortDirection,
|
||||
'backUrl' => route('business.pages.show', [
|
||||
'section' => 'financial-operations',
|
||||
'page' => 'ledger',
|
||||
]) . '?tab=accounts',
|
||||
'canEdit' => FinanceLedgerAccess::canEditAccountStatus(user()),
|
||||
'handlingMethods' => $handlingMethods,
|
||||
'completionStatuses' => $completionStatuses,
|
||||
'accountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS,
|
||||
'accountTypeOptions' => $typeOptions,
|
||||
'ownerOptions' => $ownerOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateAccountKeepTodo(Request $request)
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改不保留账户待办。');
|
||||
|
||||
$data = $request->validate([
|
||||
'account_short' => ['required', 'string', 'max:100'],
|
||||
'handling_method' => ['nullable', 'string', Rule::in(['online', 'offline'])],
|
||||
'owner_name' => ['nullable', 'string', 'max:100'],
|
||||
'started_at' => ['nullable', 'date'],
|
||||
'completed_at' => ['nullable', 'date'],
|
||||
'current_progress' => ['required', 'string', Rule::in(['completed', 'in_progress', 'not_started'])],
|
||||
]);
|
||||
|
||||
$accountShort = trim((string) $data['account_short']);
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$account = $connection->table('fin_accounts')
|
||||
->where('account_short', $accountShort)
|
||||
->first(['account_should_keep', 'company_plan']);
|
||||
abort_if($account === null, 422, '账户简称不存在,请刷新待办清单后重试。');
|
||||
abort_unless(
|
||||
FinanceLedgerAccountShouldKeep::display(
|
||||
$account->account_should_keep ?? null,
|
||||
$account->company_plan ?? null,
|
||||
) === FinanceLedgerAccountShouldKeep::NO,
|
||||
422,
|
||||
'账户当前已设置为保留,无需继续填写不保留账户待办。',
|
||||
);
|
||||
|
||||
$now = now();
|
||||
$values = [
|
||||
'handling_method' => $data['handling_method'] ?? null,
|
||||
'owner_name' => $data['owner_name'] ?? null,
|
||||
'started_at' => filled($data['started_at'] ?? null)
|
||||
? Carbon::parse($data['started_at'])->format('Y-m-d H:i:s')
|
||||
: null,
|
||||
'completed_at' => filled($data['completed_at'] ?? null)
|
||||
? Carbon::parse($data['completed_at'])->format('Y-m-d H:i:s')
|
||||
: null,
|
||||
'current_progress' => (string) $data['current_progress'],
|
||||
'updated_by' => (string) user()->name,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$todos = $connection->table('fin_account_keep_todos');
|
||||
$existing = $todos->where('account_short', $accountShort)->exists();
|
||||
if ($existing) {
|
||||
$todos->where('account_short', $accountShort)->update($values);
|
||||
} else {
|
||||
$todos->insert($values + [
|
||||
'account_short' => $accountShort,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', '不保留账户待办已保存');
|
||||
}
|
||||
|
||||
public function updateAccountTodo(Request $request, FinanceLedgerCalculator $calculator)
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改待办事项。');
|
||||
|
||||
$data = $request->validate([
|
||||
'account_short' => ['required', 'string', 'max:100'],
|
||||
'abnormal_reason' => ['nullable', 'string', 'max:1000'],
|
||||
'handling_method' => ['nullable', 'string', Rule::in(['online', 'offline'])],
|
||||
'owner_name' => ['nullable', 'string', 'max:100'],
|
||||
'started_at' => ['nullable', 'date'],
|
||||
'completed_at' => ['nullable', 'date'],
|
||||
'current_progress' => ['nullable', 'string', 'max:100'],
|
||||
'completion_status' => ['nullable', 'string', Rule::in(['completed', 'in_progress', 'not_started', 'incomplete'])],
|
||||
]);
|
||||
|
||||
$accountShort = trim((string) $data['account_short']);
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
abort_unless(
|
||||
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
|
||||
422,
|
||||
'账户简称不存在,请刷新待办清单后重试。',
|
||||
);
|
||||
|
||||
$abnormalStatuses = array_values(array_diff(
|
||||
array_keys(FinanceLedgerAccountStatus::OPTIONS),
|
||||
[FinanceLedgerAccountStatus::NORMAL],
|
||||
));
|
||||
$status = $connection->table('fin_account_statuses')
|
||||
->where('account_short', $accountShort)
|
||||
->value('status');
|
||||
abort_unless(
|
||||
$status !== null && in_array(FinanceLedgerAccountStatus::normalize((string) $status), $abnormalStatuses, true),
|
||||
422,
|
||||
'账户当前已不是异常状态,无需继续填写待办。',
|
||||
);
|
||||
|
||||
$now = now();
|
||||
$completionStatus = (string) ($data['completion_status'] ?? 'not_started');
|
||||
$values = [
|
||||
'abnormal_reason' => $data['abnormal_reason'] ?? null,
|
||||
'handling_method' => $data['handling_method'] ?? null,
|
||||
'owner_name' => $data['owner_name'] ?? null,
|
||||
'started_at' => filled($data['started_at'] ?? null)
|
||||
? Carbon::parse($data['started_at'])->format('Y-m-d H:i:s')
|
||||
: null,
|
||||
'completed_at' => filled($data['completed_at'] ?? null)
|
||||
? Carbon::parse($data['completed_at'])->format('Y-m-d H:i:s')
|
||||
: null,
|
||||
'current_progress' => $data['current_progress'] ?? null,
|
||||
'completion_status' => $completionStatus,
|
||||
'updated_by' => (string) user()->name,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$connection->transaction(function () use ($connection, $accountShort, $values, $now, $completionStatus, $calculator): void {
|
||||
$todos = $connection->table('fin_account_todos');
|
||||
$existing = $todos->where('account_short', $accountShort)->exists();
|
||||
if ($existing) {
|
||||
$todos->where('account_short', $accountShort)->update($values);
|
||||
} else {
|
||||
$todos->insert($values + [
|
||||
'account_short' => $accountShort,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($completionStatus === 'completed') {
|
||||
$connection->table('fin_account_statuses')
|
||||
->where('account_short', $accountShort)
|
||||
->update([
|
||||
'status' => FinanceLedgerAccountStatus::NORMAL,
|
||||
'updated_by' => (string) user()->name,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$calculator->refreshAccountAmounts($accountShort);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with(
|
||||
'success',
|
||||
$completionStatus === 'completed'
|
||||
? '待办已完成,账户状态已自动设为正常'
|
||||
: '待办事项已保存',
|
||||
);
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
|
||||
public function updateAccountStatus(Request $request, FinanceLedgerCalculator $calculator): JsonResponse
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户状态。');
|
||||
|
||||
$data = $request->validate([
|
||||
'account_short' => ['required', 'string', 'max:100'],
|
||||
'status' => ['required', 'string', Rule::in(array_keys(FinanceLedgerAccountStatus::OPTIONS))],
|
||||
]);
|
||||
|
||||
$accountShort = trim((string) $data['account_short']);
|
||||
abort_if($accountShort === '', 422, '账户简称不能为空。');
|
||||
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
abort_unless(
|
||||
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
|
||||
422,
|
||||
'账户简称不存在,请刷新账户总表后重试。',
|
||||
);
|
||||
|
||||
$status = FinanceLedgerAccountStatus::normalize((string) $data['status']);
|
||||
$statuses = $connection->table('fin_account_statuses');
|
||||
$existing = $statuses->where('account_short', $accountShort)->first();
|
||||
$now = now();
|
||||
$values = [
|
||||
'status' => $status,
|
||||
'updated_by' => (string) user()->name,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$connection->transaction(function () use ($statuses, $existing, $values, $accountShort, $now, $calculator): void {
|
||||
if ($existing === null) {
|
||||
$statuses->insert($values + [
|
||||
'account_short' => $accountShort,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
$statuses->where('account_short', $accountShort)->update($values);
|
||||
}
|
||||
|
||||
$calculator->refreshAccountAmounts($accountShort);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'account_short' => $accountShort,
|
||||
'status' => $status,
|
||||
'label' => FinanceLedgerAccountStatus::label($status),
|
||||
], 200, ['Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function updateAccountType(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditAccountType(user()), 403, '只有财务台账管理员可以修改账户类型。');
|
||||
|
||||
$data = $request->validate([
|
||||
'account_short' => ['required', 'string', 'max:100'],
|
||||
'account_type' => ['nullable', 'string', 'max:50'],
|
||||
]);
|
||||
|
||||
$accountShort = trim((string) $data['account_short']);
|
||||
$accountType = trim((string) ($data['account_type'] ?? ''));
|
||||
abort_if($accountShort === '', 422, '账户简称不能为空。');
|
||||
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
$account = $connection->table('fin_accounts')
|
||||
->where('account_short', $accountShort)
|
||||
->first(['account_no', 'account_last4']);
|
||||
abort_if($account === null, 422, '账户简称不存在,请刷新账户总表后重试。');
|
||||
|
||||
if ($accountType !== '') {
|
||||
$allowedTypes = FinanceLedgerAccountType::options(
|
||||
$connection->table('fin_accounts')
|
||||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||||
->distinct()->pluck('account_type')->all(),
|
||||
$connection->table('fin_account_type_overrides')
|
||||
->whereNotNull('account_type')->where('account_type', '<>', '')
|
||||
->distinct()->pluck('account_type')->all(),
|
||||
);
|
||||
abort_unless(in_array($accountType, $allowedTypes, true), 422, '请选择现有账户类型。');
|
||||
}
|
||||
|
||||
$overrides = $connection->table('fin_account_type_overrides');
|
||||
$existing = $overrides->where('account_short', $accountShort)->first();
|
||||
$now = now();
|
||||
$values = [
|
||||
'account_type' => $accountType !== '' ? $accountType : null,
|
||||
'updated_by' => (string) user()->name,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$accountLast4 = FinanceLedgerAccountType::last4(
|
||||
$accountType,
|
||||
(string) ($account->account_no ?? ''),
|
||||
(string) ($account->account_last4 ?? ''),
|
||||
);
|
||||
|
||||
$connection->transaction(function () use ($connection, $overrides, $existing, $values, $accountShort, $now, $accountLast4): void {
|
||||
if ($existing === null) {
|
||||
$overrides->insert($values + [
|
||||
'account_short' => $accountShort,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
} else {
|
||||
$overrides->where('account_short', $accountShort)->update($values);
|
||||
}
|
||||
|
||||
$connection->table('fin_accounts')
|
||||
->where('account_short', $accountShort)
|
||||
->update([
|
||||
'account_last4' => $accountLast4,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'account_short' => $accountShort,
|
||||
'account_type' => $accountType,
|
||||
'label' => $accountType !== '' ? $accountType : '未填写',
|
||||
'account_last4' => $accountLast4,
|
||||
], 200, ['Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function updateAccountPlan(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改公司规划。');
|
||||
|
||||
$data = $request->validate([
|
||||
'account_short' => ['required', 'string', 'max:100'],
|
||||
'company_plan' => ['nullable', 'string', 'max:50'],
|
||||
]);
|
||||
$accountShort = trim((string) $data['account_short']);
|
||||
$plan = trim((string) ($data['company_plan'] ?? ''));
|
||||
if ($accountShort === '') {
|
||||
abort(422, '账户简称不能为空。');
|
||||
}
|
||||
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
abort_unless(
|
||||
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
|
||||
422,
|
||||
'账户简称不存在,请刷新账户总表后重试。',
|
||||
);
|
||||
$allowedPlans = $connection->table('fin_accounts')
|
||||
->whereNotNull('company_plan')
|
||||
->where('company_plan', '<>', '')
|
||||
->distinct()
|
||||
->pluck('company_plan')
|
||||
->map(static fn (mixed $value): string => trim((string) $value))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
abort_unless($plan === '' || in_array($plan, $allowedPlans, true), 422, '请选择现有公司规划。');
|
||||
|
||||
$connection->table('fin_accounts')->where('account_short', $accountShort)->update([
|
||||
'company_plan' => $plan !== '' ? $plan : null,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'account_short' => $accountShort,
|
||||
'company_plan' => $plan,
|
||||
'label' => $plan !== '' ? $plan : '未填写',
|
||||
], 200, ['Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function updateAccountKeep(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户是否保留。');
|
||||
|
||||
$data = $request->validate([
|
||||
'account_short' => ['required', 'string', 'max:100'],
|
||||
'account_should_keep' => ['nullable', 'string', Rule::in(FinanceLedgerAccountShouldKeep::OPTIONS)],
|
||||
]);
|
||||
$accountShort = trim((string) $data['account_short']);
|
||||
$keep = FinanceLedgerAccountShouldKeep::normalize((string) ($data['account_should_keep'] ?? ''));
|
||||
if ($accountShort === '') {
|
||||
abort(422, '账户简称不能为空。');
|
||||
}
|
||||
|
||||
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
|
||||
abort_unless(
|
||||
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
|
||||
422,
|
||||
'账户简称不存在,请刷新账户总表后重试。',
|
||||
);
|
||||
|
||||
$connection->table('fin_accounts')->where('account_short', $accountShort)->update([
|
||||
'account_should_keep' => $keep !== '' ? $keep : null,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'account_short' => $accountShort,
|
||||
'account_should_keep' => $keep,
|
||||
'label' => $keep !== '' ? $keep : '未填写',
|
||||
], 200, ['Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function previewShopMapping(FinanceLedgerShopMappingBulk $bulk): JsonResponse
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。');
|
||||
|
||||
$preview = $bulk->preview();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'total' => $preview['total'],
|
||||
'matched' => $preview['matched'],
|
||||
'unmatched' => $preview['unmatched'],
|
||||
'sample' => array_slice($preview['candidates'], 0, 10),
|
||||
'unmatched_sample' => array_slice($preview['unmatched_values'], 0, 10),
|
||||
], 200, ['Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function applyShopMapping(FinanceLedgerShopMappingBulk $bulk): JsonResponse
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。');
|
||||
|
||||
$result = $bulk->apply(user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'total' => $result['total'],
|
||||
'mapped' => $result['mapped'],
|
||||
'unmatched' => $result['unmatched'],
|
||||
], 200, ['Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function previewPayableMapping(FinanceLedgerPayableMappingBulk $bulk): JsonResponse
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。');
|
||||
|
||||
$preview = $bulk->preview();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'total' => $preview['total'],
|
||||
'eligible' => $preview['eligible'],
|
||||
'ambiguous' => $preview['ambiguous'],
|
||||
'missing' => $preview['missing'],
|
||||
'sample' => array_slice($preview['candidates'], 0, 10),
|
||||
'ambiguous_sample' => array_slice($preview['ambiguous_values'], 0, 10),
|
||||
'missing_sample' => array_slice($preview['missing_values'], 0, 10),
|
||||
], 200, ['Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function applyPayableMapping(FinanceLedgerPayableMappingBulk $bulk): JsonResponse
|
||||
{
|
||||
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。');
|
||||
|
||||
$result = $bulk->apply(user());
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'total' => $result['total'],
|
||||
'mapped' => $result['mapped'],
|
||||
'ambiguous' => $result['ambiguous'],
|
||||
'missing' => $result['missing'],
|
||||
], 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 [
|
||||
@ -3265,6 +3601,8 @@ class Pages extends Controller
|
||||
'board-deductions' => $this->contentForBoardDeductions(),
|
||||
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
|
||||
'supplier-management' => app(\Modules\EcommerceFinance\View\SupplierManagementPageData::class)->page(),
|
||||
'accessory-freight-deductions' => app(\Modules\EcommerceFinance\View\SupplierAccessoryFreightPageData::class)->page(),
|
||||
'manufacturer-bookkeeping' => app(\Modules\EcommerceFinance\View\SupplierBookkeepingPageData::class)->page(),
|
||||
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
|
||||
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
|
||||
'product-costs' => $this->contentForProductCosts(),
|
||||
@ -3278,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(),
|
||||
@ -4253,6 +4594,7 @@ class Pages extends Controller
|
||||
'withdrawalBalanceSummary' => $report['summary'],
|
||||
'withdrawalBalanceColumnLabel' => $report['balanceColumnLabel'],
|
||||
'withdrawalBalanceDate' => $report['balanceDate'],
|
||||
'withdrawalBalanceDates' => $report['balanceDates'],
|
||||
'withdrawalSort' => $report['sort'],
|
||||
];
|
||||
}
|
||||
@ -4728,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('导入核验');
|
||||
@ -4795,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');
|
||||
|
||||
@ -5529,6 +5877,8 @@ class Pages extends Controller
|
||||
$columns = [
|
||||
['label' => '序号/主键ID', 'field' => 'Id', 'identifier' => true],
|
||||
['label' => '退款日期', 'field' => 'refundDate'],
|
||||
['label' => '业务来源', 'field' => 'dataSource'],
|
||||
['label' => '商品明细', 'field' => 'detailStatus'],
|
||||
['label' => '售后单ID', 'field' => 'as_id', 'identifier' => true],
|
||||
['label' => '外部售后单号', 'field' => 'outer_as_id', 'identifier' => true],
|
||||
['label' => '订单ID', 'field' => 'o_id', 'identifier' => true],
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Business/SupplierAccessoryFreight.php
Normal file
41
app/Http/Controllers/Business/SupplierAccessoryFreight.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Services\SupplierAccessoryFreightMatcher;
|
||||
use Modules\EcommerceFinance\View\SupplierAccessoryFreightPageData;
|
||||
|
||||
class SupplierAccessoryFreight extends Controller
|
||||
{
|
||||
public function export(SupplierAccessoryFreightPageData $data)
|
||||
{
|
||||
$filters = $data->filters();
|
||||
$columns = $data->exportColumns();
|
||||
$file = tmpfile();
|
||||
abort_if($file === false, 500, '无法创建导出文件。');
|
||||
try {
|
||||
fwrite($file, "\xEF\xBB\xBF");
|
||||
fputcsv($file, ['业务月份', '发货记录ID', '仓库', ...array_values($columns)]);
|
||||
foreach ($data->rows($filters) as $row) {
|
||||
if ($filters['state'] !== 'all' && $filters['state'] !== $row['state']) continue;
|
||||
$cells = array_merge($row, ['courier' => ($row['express_name1'] ?? '') ?: ($row['express_name2'] ?? ''),
|
||||
'freight' => $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2, '.', ''),
|
||||
'state' => SupplierAccessoryFreightMatcher::STATES[$row['state']], 'deduction' => number_format($row['deduction_cents'] / 100, 2, '.', '')]);
|
||||
$values = [$filters['month'], $row['source_id'], \Modules\EcommerceFinance\Services\AccessoryReshipMatcher::WAREHOUSE];
|
||||
foreach ($columns as $key => $label) $values[] = $cells[$key];
|
||||
fputcsv($file, array_map(static function ($value) {
|
||||
$value = (string) $value;
|
||||
// 纯数字标识保留文本,防止 Excel 截断长单号;同时阻止公式执行。
|
||||
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
|
||||
}, $values));
|
||||
}
|
||||
rewind($file);
|
||||
} catch (\Throwable $exception) {
|
||||
fclose($file);
|
||||
report($exception);
|
||||
abort(503, '资料读取失败,未生成不完整的扣款明细,请稍后重试。');
|
||||
}
|
||||
return response()->streamDownload(function () use ($file) { fpassthru($file); fclose($file); }, SupplierAccessoryFreightPageData::TITLE . '-' . $filters['month'] . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
}
|
||||
44
app/Http/Controllers/Business/SupplierBookkeeping.php
Normal file
44
app/Http/Controllers/Business/SupplierBookkeeping.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\View\SupplierBookkeepingPageData;
|
||||
|
||||
class SupplierBookkeeping extends Controller
|
||||
{
|
||||
public function export(SupplierBookkeepingPageData $data)
|
||||
{
|
||||
$filters = $data->filters();
|
||||
$columns = $data->exportColumns();
|
||||
$file = tmpfile();
|
||||
abort_if($file === false, 500, '无法创建导出文件。');
|
||||
|
||||
try {
|
||||
fwrite($file, "\xEF\xBB\xBF");
|
||||
fputcsv($file, array_values($columns));
|
||||
foreach ($data->rows($filters) as $row) {
|
||||
$values = [];
|
||||
foreach (array_keys($columns) as $key) {
|
||||
$values[] = $row[$key] ?? '';
|
||||
}
|
||||
fputcsv($file, array_map(static function (mixed $value): string {
|
||||
$value = (string) $value;
|
||||
|
||||
// 长数字单号保留文本,避免 Excel 截断;同时阻止公式执行。
|
||||
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
|
||||
}, $values));
|
||||
}
|
||||
rewind($file);
|
||||
} catch (\Throwable $exception) {
|
||||
fclose($file);
|
||||
report($exception);
|
||||
abort(503, '聚工单镜像未就绪或读取失败,未生成不完整的厂家记账,请稍后重试。');
|
||||
}
|
||||
|
||||
return response()->streamDownload(static function () use ($file): void {
|
||||
fpassthru($file);
|
||||
fclose($file);
|
||||
}, SupplierBookkeepingPageData::TITLE . '-' . now()->format('YmdHis') . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -2,26 +2,41 @@
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\SupplierFreeShippingStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierFreeShippingWorkbook;
|
||||
use Modules\EcommerceFinance\View\SupplierManagementPageData;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SupplierManagement extends Controller
|
||||
{
|
||||
public function export(Request $request, SupplierManagementMirrorRepository $repository, SupplierManagementPageData $page)
|
||||
{
|
||||
public function export(
|
||||
Request $request,
|
||||
SupplierManagementMirrorRepository $repository,
|
||||
SupplierManagementPageData $page,
|
||||
SupplierFreeShippingStore $freeShipping,
|
||||
): StreamedResponse {
|
||||
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能导出。');
|
||||
$filters = $page->filters($request->query());
|
||||
$columns = SupplierManagementPageData::columns();
|
||||
$companyId = (int) (company_id() ?: 1);
|
||||
|
||||
return response()->streamDownload(static function () use ($repository, $filters): void {
|
||||
return response()->streamDownload(static function () use ($repository, $filters, $columns, $freeShipping, $companyId): void {
|
||||
$csv = fopen('php://output', 'w');
|
||||
fputs($csv, "\xEF\xBB\xBF");
|
||||
fputcsv($csv, array_values(SupplierManagementMirrorRepository::COLUMNS));
|
||||
fputcsv($csv, array_values($columns));
|
||||
foreach ($repository->exportRows($filters) as $row) {
|
||||
$row = $freeShipping->decorateRow($row, $companyId);
|
||||
$values = [];
|
||||
foreach (array_keys(SupplierManagementMirrorRepository::COLUMNS) as $column) {
|
||||
$value = (string) ($row->{$column} ?? '');
|
||||
foreach (array_keys($columns) as $column) {
|
||||
$value = $column === SupplierManagementPageData::FREE_SHIPPING_COLUMN
|
||||
? ((bool) ($row->{$column} ?? false) ? '包邮' : '')
|
||||
: (string) ($row->{$column} ?? '');
|
||||
if ($column === 'coopersgn') $value = ['1' => '合作', '2' => '不合作'][$value] ?? '未维护';
|
||||
// Excel 不能执行供应商提供的公式文本,且长编码保留为文本。
|
||||
if (preg_match('/^[=+\-@\t\r\n]/', ltrim($value)) || preg_match('/^\d{12,}$/', $value)) $value = "'" . $value;
|
||||
@ -33,4 +48,114 @@ class SupplierManagement extends Controller
|
||||
}, 'supplier_management_' . now()->format('Ymd_His') . '.csv',
|
||||
['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function template(SupplierFreeShippingWorkbook $workbook): StreamedResponse
|
||||
{
|
||||
return $workbook->template();
|
||||
}
|
||||
|
||||
public function import(
|
||||
Request $request,
|
||||
SupplierManagementMirrorRepository $repository,
|
||||
SupplierFreeShippingStore $freeShipping,
|
||||
SupplierFreeShippingWorkbook $workbook,
|
||||
): RedirectResponse {
|
||||
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]);
|
||||
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能导入。');
|
||||
|
||||
$valuesByCode = $workbook->read($request->file('import'));
|
||||
$suppliers = $repository->suppliersByCodes(array_keys($valuesByCode));
|
||||
$matched = [];
|
||||
foreach ($suppliers as $supplier) {
|
||||
$code = mb_strtolower(trim((string) ($supplier->scmcode ?? '')));
|
||||
if ($code !== '') {
|
||||
$matched[$code][] = $supplier;
|
||||
}
|
||||
}
|
||||
|
||||
$missing = [];
|
||||
$ambiguous = [];
|
||||
$valuesBySupplierId = [];
|
||||
foreach ($valuesByCode as $code => $isFreeShipping) {
|
||||
$key = mb_strtolower(trim((string) $code));
|
||||
if (! isset($matched[$key])) {
|
||||
$missing[] = (string) $code;
|
||||
continue;
|
||||
}
|
||||
if (count($matched[$key]) !== 1) {
|
||||
$ambiguous[] = (string) $code;
|
||||
continue;
|
||||
}
|
||||
$valuesBySupplierId[(int) $matched[$key][0]->Id] = $isFreeShipping;
|
||||
}
|
||||
if ($ambiguous !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '以下供应商代码在供应商主档中存在多条记录,未导入:' . implode('、', array_slice($ambiguous, 0, 8)),
|
||||
]);
|
||||
}
|
||||
if ($missing !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '以下供应商代码未找到,未导入:' . implode('、', array_slice($missing, 0, 8))
|
||||
. (count($missing) > 8 ? ' 等 ' . count($missing) . ' 条' : ''),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $freeShipping->saveMany(
|
||||
$valuesBySupplierId,
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
'import',
|
||||
);
|
||||
|
||||
return $this->redirectToPage($request)->with(
|
||||
'supplier_management_message',
|
||||
"导入完成:新增 {$result['created']} 家供应商维护记录,更新 {$result['updated']} 家供应商维护记录。",
|
||||
);
|
||||
}
|
||||
|
||||
public function updateFreeShipping(
|
||||
Request $request,
|
||||
int $supplier,
|
||||
SupplierManagementMirrorRepository $repository,
|
||||
SupplierFreeShippingStore $freeShipping,
|
||||
): JsonResponse|RedirectResponse {
|
||||
$request->validate(['value' => ['nullable', 'string', 'in:,包邮']]);
|
||||
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能编辑。');
|
||||
abort_if($repository->supplierById($supplier) === null, 404, '供应商不存在。');
|
||||
|
||||
$isFreeShipping = $request->input('value') === '包邮';
|
||||
$freeShipping->save(
|
||||
$supplier,
|
||||
$isFreeShipping,
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
'manual',
|
||||
);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'value' => $isFreeShipping ? '包邮' : '',
|
||||
'display' => $isFreeShipping ? '包邮' : '-',
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->redirectToPage($request)->with('supplier_management_message', '是否包邮厂家已保存。');
|
||||
}
|
||||
|
||||
private function redirectToPage(Request $request): RedirectResponse
|
||||
{
|
||||
$query = array_filter([
|
||||
...app(SupplierManagementPageData::class)->filters($request->query()),
|
||||
'page' => max((int) $request->query('page', 1), 1),
|
||||
], static fn (mixed $value): bool => $value !== '');
|
||||
|
||||
$url = route('business.pages.show', [
|
||||
'company_id' => company_id() ?: 1,
|
||||
'section' => 'supplier-payables',
|
||||
'page' => 'supplier-management',
|
||||
]);
|
||||
|
||||
return redirect()->to($url . ($query === [] ? '' : '?' . http_build_query($query)));
|
||||
}
|
||||
}
|
||||
|
||||
18
app/Http/Controllers/Business/SupplierPortal.php
Normal file
18
app/Http/Controllers/Business/SupplierPortal.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class SupplierPortal extends Controller
|
||||
{
|
||||
public function home(): RedirectResponse
|
||||
{
|
||||
return redirect()->route('business.pages.show', [
|
||||
'company_id' => company_id(),
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'supplier-data',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -2,13 +2,22 @@
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Services\Auth\SupplierPortalAccess;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Services\SupplierComparisonCsvExporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
|
||||
use Modules\EcommerceFinance\View\SupplierReconciliationPageData;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 供应商对账:对账单模板下载 + 厂家对账单上传(严格按模板校验)。
|
||||
@ -18,6 +27,12 @@ class SupplierReconciliation extends Controller
|
||||
public function __construct(
|
||||
private readonly SupplierStatementTemplate $template,
|
||||
private readonly SupplierStatementStore $statements,
|
||||
private readonly SupplierReturnConfirmation $returnConfirmations,
|
||||
private readonly SupplierReconciliationPageData $pageData,
|
||||
private readonly SupplierReturnCsvExporter $returnCsvExporter,
|
||||
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
|
||||
private readonly SupplierComparisonCsvExporter $comparisonCsvExporter,
|
||||
private readonly SupplierPortalAccess $supplierPortalAccess,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
@ -35,13 +50,9 @@ class SupplierReconciliation extends Controller
|
||||
{
|
||||
$request->validate([
|
||||
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'],
|
||||
'supplier' => ['required', 'string', 'max:191'],
|
||||
]);
|
||||
|
||||
$supplier = trim((string) $request->input('supplier'));
|
||||
if ($supplier === '') {
|
||||
abort(422, '请选择厂家。');
|
||||
}
|
||||
$supplier = $this->effectiveSupplier($request);
|
||||
|
||||
$records = $this->template->parse($request->file('import'), $supplier);
|
||||
$rows = $this->statements->save(
|
||||
@ -58,4 +69,196 @@ class SupplierReconciliation extends Controller
|
||||
? response()->json(['message' => $message, 'rows' => $rows])
|
||||
: back()->with('success', $message);
|
||||
}
|
||||
|
||||
public function confirmReturn(Request $request): RedirectResponse
|
||||
{
|
||||
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_CONFIRMED, '已确认该行退货扣减。');
|
||||
}
|
||||
|
||||
public function disputeReturn(Request $request): RedirectResponse
|
||||
{
|
||||
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_DISPUTED, '已提交异议,复核通过前该行暂不扣减。');
|
||||
}
|
||||
|
||||
public function confirmAllReturns(Request $request): RedirectResponse
|
||||
{
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$keys = array_values(array_map(
|
||||
static fn (array $row): string => (string) ($row['_action_key'] ?? ''),
|
||||
$this->pageData->returnRowsForFilters($supplier, $companyId, $request->all()),
|
||||
));
|
||||
$confirmed = $this->returnConfirmations->confirmAll(
|
||||
$supplier,
|
||||
$keys,
|
||||
$companyId,
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
return back()->with('success', "已全部确认:{$confirmed} 行退货扣减生效(异议行保持暂不扣减)。");
|
||||
}
|
||||
|
||||
public function exportReturns(Request $request): StreamedResponse
|
||||
{
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$rows = $this->pageData->returnRowsForFilters($supplier, $companyId, $request->query());
|
||||
|
||||
return $this->returnCsvExporter->download($rows, $supplier);
|
||||
}
|
||||
|
||||
public function exportComparison(Request $request): StreamedResponse
|
||||
{
|
||||
$request->validate([
|
||||
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
|
||||
'reconciler' => ['nullable', 'string', 'max:191'],
|
||||
]);
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->query());
|
||||
|
||||
return $this->comparisonCsvExporter->download(
|
||||
$detail,
|
||||
$supplier,
|
||||
trim((string) $request->query('month', '')),
|
||||
trim((string) $request->query('reconciler', '')),
|
||||
);
|
||||
}
|
||||
|
||||
public function saveDingTalkRobot(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'supplier' => ['required', 'string', 'max:191'],
|
||||
'dingtalk_webhook' => ['nullable', 'string', 'max:2048'],
|
||||
'dingtalk_secret' => ['nullable', 'string', 'max:256'],
|
||||
'dingtalk_at_all' => ['nullable', 'boolean'],
|
||||
]);
|
||||
[$supplier] = $this->returnTarget($request);
|
||||
$this->dingTalkNotifier->save(
|
||||
$supplier,
|
||||
(string) $request->input('dingtalk_webhook', ''),
|
||||
(string) $request->input('dingtalk_secret', ''),
|
||||
$request->boolean('dingtalk_at_all'),
|
||||
);
|
||||
|
||||
return back()->with('success', "{$supplier} 的钉钉群机器人已保存。");
|
||||
}
|
||||
|
||||
public function pushDifferences(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'supplier' => ['required', 'string', 'max:191'],
|
||||
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
|
||||
'reconciler' => ['nullable', 'string', 'max:191'],
|
||||
'scope' => ['required', 'in:all,row,selected'],
|
||||
'purchase_no' => ['nullable', 'string', 'max:191'],
|
||||
'date' => ['nullable', 'string', 'max:32'],
|
||||
'barcode' => ['nullable', 'string', 'max:191'],
|
||||
'difference_type' => ['nullable', 'string', 'max:32'],
|
||||
'selected_rows' => ['nullable', 'array', 'max:600'],
|
||||
'selected_rows.*' => ['string', 'max:2048'],
|
||||
]);
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->all());
|
||||
$differences = array_values(array_filter(
|
||||
$detail['rows'],
|
||||
static fn (array $row): bool => ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH,
|
||||
));
|
||||
|
||||
if ($request->input('scope') === 'row') {
|
||||
$purchaseNo = trim((string) $request->input('purchase_no'));
|
||||
$date = trim((string) $request->input('date'));
|
||||
$barcode = trim((string) $request->input('barcode'));
|
||||
$type = trim((string) $request->input('difference_type'));
|
||||
$differences = array_values(array_filter(
|
||||
$differences,
|
||||
static fn (array $row): bool => (string) ($row['purchaseNo'] ?? '') === $purchaseNo
|
||||
&& (string) ($row['date'] ?? '') === $date
|
||||
&& (string) ($row['barcode'] ?? '') === $barcode
|
||||
&& (string) ($row['type'] ?? '') === $type
|
||||
));
|
||||
} elseif ($request->input('scope') === 'selected') {
|
||||
$selected = [];
|
||||
foreach ((array) $request->input('selected_rows', []) as $token) {
|
||||
$row = json_decode((string) $token, true);
|
||||
if (is_array($row)) {
|
||||
$selected[$this->differenceRowKey($row)] = true;
|
||||
}
|
||||
}
|
||||
$differences = array_values(array_filter(
|
||||
$differences,
|
||||
fn (array $row): bool => isset($selected[$this->differenceRowKey($row)]),
|
||||
));
|
||||
}
|
||||
|
||||
if ($differences === []) {
|
||||
return back()->withErrors(['dingtalk_push' => '当前筛选没有可推送的差异。']);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->dingTalkNotifier->push($supplier, $differences, [
|
||||
'month' => (string) $request->input('month', ''),
|
||||
'reconciler' => (string) $request->input('reconciler', ''),
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
return back()->withErrors(['dingtalk_push' => '推送失败:' . $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with(
|
||||
'success',
|
||||
"已推送 {$result['rows']} 条差异到 {$supplier} 钉钉群({$result['messages']} 条消息)。",
|
||||
);
|
||||
}
|
||||
|
||||
private function markReturn(Request $request, string $status, string $message): RedirectResponse
|
||||
{
|
||||
$request->validate(['key' => ['required', 'string', 'max:191']]);
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$key = (string) $request->input('key');
|
||||
$owned = collect($this->pageData->returnRows($supplier, $companyId))
|
||||
->contains(static fn (array $row): bool => (string) ($row['_action_key'] ?? '') === $key);
|
||||
abort_unless($owned, 403, '该退货明细不属于当前厂家账号。');
|
||||
|
||||
$this->returnConfirmations->mark(
|
||||
$supplier,
|
||||
$key,
|
||||
$status,
|
||||
$companyId,
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:int} */
|
||||
private function returnTarget(Request $request): array
|
||||
{
|
||||
return [$this->effectiveSupplier($request), (int) (company_id() ?: 1)];
|
||||
}
|
||||
|
||||
private function effectiveSupplier(Request $request): string
|
||||
{
|
||||
$user = $request->user();
|
||||
if ($user && SupplierPortalAccess::isSupplierUser($user)) {
|
||||
$supplier = $this->supplierPortalAccess->supplierFor($user);
|
||||
abort_if(! $supplier, 403, '当前钉钉账号未绑定有效供应商,请联系财务人员。');
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
$request->validate(['supplier' => ['required', 'string', 'max:191']]);
|
||||
$supplier = trim((string) $request->input('supplier'));
|
||||
abort_if($supplier === '', 422, '请选择厂家。');
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function differenceRowKey(array $row): string
|
||||
{
|
||||
return implode("\0", [
|
||||
trim((string) ($row['purchaseNo'] ?? $row['purchase_no'] ?? '')),
|
||||
trim((string) ($row['date'] ?? '')),
|
||||
trim((string) ($row['barcode'] ?? '')),
|
||||
trim((string) ($row['type'] ?? '')),
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Models\Common\Contact;
|
||||
use App\Models\Document\Document;
|
||||
use App\Traits\Charts;
|
||||
use App\Traits\DateTime;
|
||||
@ -20,6 +21,10 @@ class Dashboard
|
||||
{
|
||||
$contact = user()->contact;
|
||||
|
||||
if ($contact instanceof Contact && $contact->type === Contact::VENDOR_TYPE) {
|
||||
return redirect()->route('portal.supplier-reconciliation.index', ['company_id' => company_id()]);
|
||||
}
|
||||
|
||||
// Redirect user redirect landing page..
|
||||
if (!$contact) {
|
||||
return redirect(user()->getLandingPageOfUser());
|
||||
|
||||
154
app/Http/Controllers/Portal/SupplierReconciliation.php
Normal file
154
app/Http/Controllers/Portal/SupplierReconciliation.php
Normal file
@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Models\Common\Contact;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\View\View;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
|
||||
use Modules\EcommerceFinance\View\SupplierReconciliationPageData;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* 厂家门户:账号绑定供应商后,仅可查看和操作自己的对账数据。
|
||||
*/
|
||||
class SupplierReconciliation extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SupplierStatementTemplate $template,
|
||||
private readonly SupplierStatementStore $statements,
|
||||
private readonly SupplierReturnConfirmation $returnConfirmations,
|
||||
private readonly SupplierReconciliationPageData $pageData,
|
||||
private readonly SupplierReturnCsvExporter $returnCsvExporter,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
[$contact, $supplier, $companyId] = $this->supplierContext();
|
||||
$data = $this->pageData->portalData($supplier, $companyId);
|
||||
|
||||
return view('portal.supplier-reconciliation.index', array_merge($data, [
|
||||
'contact' => $contact,
|
||||
'supplier' => $supplier,
|
||||
'statementTemplateUrl' => route('portal.supplier-reconciliation.template'),
|
||||
'uploadUrl' => route('portal.supplier-reconciliation.statement.upload'),
|
||||
'returnConfirmUrl' => route('portal.supplier-reconciliation.return.confirm'),
|
||||
'returnDisputeUrl' => route('portal.supplier-reconciliation.return.dispute'),
|
||||
'returnConfirmAllUrl' => route('portal.supplier-reconciliation.return.confirm-all'),
|
||||
]));
|
||||
}
|
||||
|
||||
public function template(): BinaryFileResponse
|
||||
{
|
||||
$this->supplierContext();
|
||||
$path = $this->template->path();
|
||||
abort_unless(is_file($path), 404, '对账单模板不存在。');
|
||||
|
||||
return response()->download($path, $this->template->filename());
|
||||
}
|
||||
|
||||
public function upload(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'],
|
||||
]);
|
||||
[, $supplier, $companyId] = $this->supplierContext();
|
||||
|
||||
$records = $this->template->parse($request->file('import'), $supplier);
|
||||
$rows = $this->statements->save(
|
||||
$supplier,
|
||||
$records,
|
||||
(string) $request->file('import')->getClientOriginalName(),
|
||||
$companyId,
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
$message = "对账单已保存:{$supplier} 共 {$rows} 行。";
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['message' => $message, 'rows' => $rows])
|
||||
: redirect()
|
||||
->route('portal.supplier-reconciliation.index', ['company_id' => $companyId])
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
public function confirmReturn(Request $request): RedirectResponse
|
||||
{
|
||||
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_CONFIRMED, '已确认该行退货扣减。');
|
||||
}
|
||||
|
||||
public function disputeReturn(Request $request): RedirectResponse
|
||||
{
|
||||
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_DISPUTED, '已提交异议,复核通过前该行暂不扣减。');
|
||||
}
|
||||
|
||||
public function confirmAllReturns(Request $request): RedirectResponse
|
||||
{
|
||||
[, $supplier, $companyId] = $this->supplierContext();
|
||||
$keys = array_values(array_map(
|
||||
static fn (array $row): string => (string) ($row['_action_key'] ?? ''),
|
||||
$this->pageData->returnRowsForFilters($supplier, $companyId, $request->all()),
|
||||
));
|
||||
$confirmed = $this->returnConfirmations->confirmAll(
|
||||
$supplier,
|
||||
$keys,
|
||||
$companyId,
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
return back()->with('success', "已全部确认:{$confirmed} 行退货扣减生效(异议行保持暂不扣减)。");
|
||||
}
|
||||
|
||||
public function exportReturns(Request $request): StreamedResponse
|
||||
{
|
||||
[, $supplier, $companyId] = $this->supplierContext();
|
||||
$rows = $this->pageData->returnRowsForFilters($supplier, $companyId, $request->query());
|
||||
|
||||
return $this->returnCsvExporter->download($rows, $supplier);
|
||||
}
|
||||
|
||||
private function markReturn(Request $request, string $status, string $message): RedirectResponse
|
||||
{
|
||||
$request->validate(['key' => ['required', 'string', 'max:191']]);
|
||||
[, $supplier, $companyId] = $this->supplierContext();
|
||||
$key = (string) $request->input('key');
|
||||
$owned = collect($this->pageData->returnRows($supplier, $companyId))
|
||||
->contains(static fn (array $row): bool => (string) ($row['_action_key'] ?? '') === $key);
|
||||
abort_unless($owned, 403, '该退货明细不属于当前厂家账号。');
|
||||
|
||||
$this->returnConfirmations->mark(
|
||||
$supplier,
|
||||
$key,
|
||||
$status,
|
||||
$companyId,
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
|
||||
/** @return array{0:Contact,1:string,2:int} */
|
||||
private function supplierContext(): array
|
||||
{
|
||||
$contact = user()->contact;
|
||||
abort_unless(
|
||||
$contact instanceof Contact
|
||||
&& $contact->type === Contact::VENDOR_TYPE
|
||||
&& $contact->enabled,
|
||||
403,
|
||||
'当前账号未绑定有效的厂家资料,请联系财务人员。',
|
||||
);
|
||||
|
||||
$supplier = trim((string) $contact->name);
|
||||
abort_if($supplier === '', 403, '当前厂家账号未设置供应商名称,请联系财务人员。');
|
||||
|
||||
return [$contact, $supplier, (int) (company_id() ?: 1)];
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Auth\SupplierPortalAccess;
|
||||
use App\Utilities\SidebarPermissions;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
@ -12,7 +13,35 @@ class EnsureLimitedDingTalkPageAccess
|
||||
{
|
||||
$user = $request->user();
|
||||
$route = $request->route();
|
||||
if (! $user || ! $route?->hasParameter('company_id') || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) {
|
||||
if (! $user || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (SupplierPortalAccess::isSupplierUser($user)) {
|
||||
if ($route?->named('logout', 'business.supplier-portal.home')) {
|
||||
return $next($request);
|
||||
}
|
||||
if ($route?->named('dashboard') && $route->hasParameter('company_id')) {
|
||||
return redirect()->route('business.supplier-portal.home', [
|
||||
'company_id' => $route->parameter('company_id'),
|
||||
]);
|
||||
}
|
||||
if ($route?->named('business.pages.show')
|
||||
&& $route->parameter('section') === 'supplier-reconciliation'
|
||||
&& $route->parameter('page') === 'supplier-data') {
|
||||
return $next($request);
|
||||
}
|
||||
foreach ($route?->gatherMiddleware() ?? [] as $middleware) {
|
||||
if (is_string($middleware) && str_starts_with($middleware, 'business.page:')
|
||||
&& str_replace(',', '/', substr($middleware, strlen('business.page:'))) === SupplierPortalAccess::PAGE_KEY) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
abort(403, '当前账号没有该页面权限。');
|
||||
}
|
||||
|
||||
if (! $route?->hasParameter('company_id')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
||||
@ -3,8 +3,9 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Auth\DingTalkIdentity;
|
||||
use App\Services\Auth\FinanceRoster;
|
||||
use App\Services\Auth\DingTalkClosedAccess;
|
||||
use App\Services\Auth\FinanceRoster;
|
||||
use App\Services\Auth\SupplierPortalAccess;
|
||||
use Closure;
|
||||
use Throwable;
|
||||
|
||||
@ -15,7 +16,11 @@ class LogoutIfDingTalkBindingRevoked
|
||||
$identityId = $request->session()->get('dingtalk_identity_id');
|
||||
$user = $request->user();
|
||||
if (! $identityId) {
|
||||
if (! $user || ! in_array($user->created_from, ['dingtalk_roster', 'dingtalk_roster_limited'], true)) {
|
||||
if (! $user || ! in_array($user->created_from, [
|
||||
'dingtalk_roster',
|
||||
'dingtalk_roster_limited',
|
||||
SupplierPortalAccess::CREATED_FROM,
|
||||
], true)) {
|
||||
return $next($request);
|
||||
}
|
||||
// A password reset must not bypass HR eligibility for provisioned
|
||||
@ -32,7 +37,9 @@ class LogoutIfDingTalkBindingRevoked
|
||||
return $next($request);
|
||||
}
|
||||
try {
|
||||
$eligible = app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id);
|
||||
$eligible = SupplierPortalAccess::isSupplierUser($user)
|
||||
? app(SupplierPortalAccess::class)->revalidate($user) !== null
|
||||
: app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id);
|
||||
} catch (Throwable) {
|
||||
// Retain the session during an HR outage, but do not serve data
|
||||
// using an expired authorization decision.
|
||||
@ -46,7 +53,11 @@ class LogoutIfDingTalkBindingRevoked
|
||||
}
|
||||
|
||||
app(DingTalkClosedAccess::class)->audit('session_revoked', 'denied', $user?->id, null, $identity?->union_id, $request, [
|
||||
'reason' => ! $user?->enabled ? 'local_account_ineligible' : (! $identity ? 'binding_revoked' : 'outside_finance_roster'),
|
||||
'reason' => ! $user?->enabled
|
||||
? 'local_account_ineligible'
|
||||
: (! $identity
|
||||
? 'binding_revoked'
|
||||
: (SupplierPortalAccess::isSupplierUser($user) ? 'outside_supplier_roster' : 'outside_finance_roster')),
|
||||
]);
|
||||
auth()->logout();
|
||||
$request->session()->invalidate();
|
||||
|
||||
28
app/Listeners/Common/RequireImportBatchContainer.php
Normal file
28
app/Listeners/Common/RequireImportBatchContainer.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Common;
|
||||
|
||||
use Illuminate\Console\Events\CommandStarting;
|
||||
use RuntimeException;
|
||||
|
||||
class RequireImportBatchContainer
|
||||
{
|
||||
public function handle(CommandStarting $event): void
|
||||
{
|
||||
if (! app()->environment('production') || config('finance_batch.container')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$command = (string) $event->command;
|
||||
$registered = in_array($command, config('finance_batch.import_commands', []), true);
|
||||
// New import commands must be registered before they can run in production.
|
||||
$isImport = preg_match('/(^|:)import(?:$|[-:])/', $command) === 1;
|
||||
|
||||
if ($registered || $isImport) {
|
||||
throw new RuntimeException(
|
||||
'生产重型导入必须在受限独立批处理容器中执行,请使用:sh /opt/caiwuxitong/run-finance-batch.sh '
|
||||
. $command . ' <原参数>。网页容器未执行本次导入。'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -113,9 +113,13 @@ class ShowInAdmin
|
||||
continue;
|
||||
}
|
||||
|
||||
$sectionPages = array_values(array_filter(
|
||||
$section['pages'] ?? [],
|
||||
static fn (array $page): bool => ($page['show_in_section_menu'] ?? true) !== false
|
||||
));
|
||||
$pages = $section['slug'] === 'order-data'
|
||||
? array_values($section['pages'] ?? [])
|
||||
: $this->sortByTitleLength($section['pages'] ?? []);
|
||||
? $sectionPages
|
||||
: $this->sortByTitleLength($sectionPages);
|
||||
$order = ($sectionIndex + 1) * 10;
|
||||
|
||||
if (empty($pages) || in_array($section['slug'], self::WORKSPACE_SECTION_SLUGS, true)) {
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Listeners\Menu;
|
||||
|
||||
use App\Events\Menu\PortalCreated as Event;
|
||||
use App\Models\Common\Contact;
|
||||
|
||||
class ShowInPortal
|
||||
{
|
||||
@ -16,6 +17,19 @@ class ShowInPortal
|
||||
{
|
||||
$user = user();
|
||||
$menu = $event->menu;
|
||||
$contact = $user->contact;
|
||||
|
||||
if ($contact instanceof Contact && $contact->type === Contact::VENDOR_TYPE) {
|
||||
$menu->route(
|
||||
'portal.supplier-reconciliation.index',
|
||||
'供应商对账',
|
||||
[],
|
||||
10,
|
||||
['icon' => 'fact_check'],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$inactive = false;
|
||||
|
||||
|
||||
28
app/Models/Auth/SupplierPortalIdentity.php
Normal file
28
app/Models/Auth/SupplierPortalIdentity.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Auth;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SupplierPortalIdentity extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'user_id',
|
||||
'supplier_code',
|
||||
'supplier_name',
|
||||
'dingtalk_group_id',
|
||||
'dingtalk_group_name',
|
||||
'last_verified_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'dingtalk_group_id' => 'integer',
|
||||
'last_verified_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@ -83,6 +83,11 @@ class User extends Authenticatable implements HasLocalePreference
|
||||
return $this->hasOne(DingTalkIdentity::class, 'user_id');
|
||||
}
|
||||
|
||||
public function supplierPortalIdentity()
|
||||
{
|
||||
return $this->hasOne(SupplierPortalIdentity::class, 'user_id');
|
||||
}
|
||||
|
||||
public function contact()
|
||||
{
|
||||
return $this->hasOne('App\Models\Common\Contact', 'user_id', 'id');
|
||||
|
||||
@ -34,6 +34,7 @@ class CourierQuoteChangeEvent extends Model
|
||||
'returned' => '退回修改',
|
||||
'approved' => '审核通过',
|
||||
'voided' => '版本作废',
|
||||
'period_end_updated' => '设置执行截止日期',
|
||||
default => $this->event_type,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,12 +27,14 @@ class CourierQuoteVersion extends Model
|
||||
'effective_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'voided_at' => 'datetime',
|
||||
'billing_structure' => 'array',
|
||||
'billing_rows' => 'array',
|
||||
];
|
||||
|
||||
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
|
||||
public function isTemporary(): bool
|
||||
{
|
||||
return $this->end_date !== null;
|
||||
return $this->end_date !== null && !$this->change?->isStockQuote();
|
||||
}
|
||||
|
||||
/** 某一天是否在这一版的执行期间内(开始 ≤ 日期 ≤ 结束;长期版本没有结束)。 */
|
||||
@ -52,15 +55,37 @@ class CourierQuoteVersion extends Model
|
||||
}
|
||||
|
||||
return $this->end_date !== null
|
||||
? $this->effective_date->toDateString() . ' 至 ' . $this->end_date->toDateString() . '(临时)'
|
||||
? $this->effective_date->toDateString() . ' 至 ' . $this->end_date->toDateString() . ($this->isTemporary() ? '(临时)' : '')
|
||||
: $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;
|
||||
}
|
||||
|
||||
/** 存量报价审核后按执行日期生效,不以补签或历史签署资料为前置条件。 */
|
||||
public function isApprovedStockQuote(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED
|
||||
&& $this->change?->status === CourierQuoteChange::STATUS_APPROVED
|
||||
&& $this->change->isStockQuote();
|
||||
}
|
||||
|
||||
public function signatureLabel(): string
|
||||
{
|
||||
return $this->isApprovedStockQuote() && !in_array($this->signature_status, ['历史签署资料已归档', '已签署并归档'], true)
|
||||
? '存量报价,审核生效(无需签章)' : (string) $this->signature_status;
|
||||
}
|
||||
|
||||
public function change(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CourierQuoteChange::class, 'source_change_id');
|
||||
|
||||
@ -11,10 +11,10 @@ class CourierStockQuoteEntry extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'courier_expcode', 'courier_name', 'courier_name_key', 'quote_type', 'source_change_id',
|
||||
'sheet_name', 'source_range', 'values_json', 'formulas_json', 'files', 'created_by',
|
||||
'sheet_name', 'source_range', 'values_json', 'formulas_json', 'files', 'created_by', 'effective_date', 'end_date',
|
||||
];
|
||||
|
||||
protected $casts = ['company_id' => 'integer', 'files' => 'array'];
|
||||
protected $casts = ['company_id' => 'integer', 'files' => 'array', 'effective_date' => 'date', 'end_date' => 'date'];
|
||||
|
||||
public function change(): BelongsTo
|
||||
{
|
||||
|
||||
@ -46,6 +46,7 @@ class Event extends Provider
|
||||
],
|
||||
//'Illuminate\Console\Events\ScheduledTaskStarting' => [
|
||||
\Illuminate\Console\Events\CommandStarting::class => [
|
||||
\App\Listeners\Common\RequireImportBatchContainer::class,
|
||||
\App\Listeners\Common\SkipScheduleInReadOnlyMode::class,
|
||||
],
|
||||
\App\Events\Auth\LandingPageShowing::class => [
|
||||
|
||||
@ -28,9 +28,13 @@ class DingTalkClosedAccess
|
||||
}
|
||||
|
||||
$roster = app(FinanceRoster::class)->findEligible((string) ($profile['dingtalk_user_id'] ?? ''));
|
||||
if (! $roster) {
|
||||
$supplierRoster = $roster ? null : app(SupplierPortalAccess::class)->resolve(
|
||||
(string) ($profile['dingtalk_user_id'] ?? ''),
|
||||
(string) ($profile['union_id'] ?? ''),
|
||||
);
|
||||
if (! $roster && ! $supplierRoster) {
|
||||
$this->audit('login_denied', 'denied', null, null, $profile['union_id'] ?? null, $request, [
|
||||
'reason' => 'outside_finance_roster',
|
||||
'reason' => 'outside_authorized_roster',
|
||||
]);
|
||||
$request->attributes->set('dingtalk_denial', 'outside_roster');
|
||||
return null;
|
||||
@ -49,6 +53,23 @@ class DingTalkClosedAccess
|
||||
|
||||
return null;
|
||||
}
|
||||
if (SupplierPortalAccess::isSupplierUser($identity->user)) {
|
||||
$company = $this->enabledCompany($identity->user);
|
||||
if (! $supplierRoster || ! $company) {
|
||||
$this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [
|
||||
'reason' => 'outside_supplier_roster',
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
app(SupplierPortalAccess::class)->bind($identity->user, (int) $company->id, $supplierRoster);
|
||||
} elseif (! $roster) {
|
||||
$this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [
|
||||
'reason' => 'outside_finance_roster',
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$identity->forceFill([
|
||||
'open_id' => $profile['open_id'] ?: $identity->open_id,
|
||||
@ -69,7 +90,7 @@ class DingTalkClosedAccess
|
||||
return null;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($profile, $roster, $request): ?DingTalkIdentity {
|
||||
return DB::transaction(function () use ($profile, $roster, $supplierRoster, $request): ?DingTalkIdentity {
|
||||
// Serialize first-time provisioning on the target company. Both the
|
||||
// account and unique identity are committed together, including retries.
|
||||
$companies = Company::enabled()
|
||||
@ -92,10 +113,11 @@ class DingTalkClosedAccess
|
||||
return null;
|
||||
}
|
||||
|
||||
$roleName = FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id'])
|
||||
$supplierPortal = $supplierRoster !== null;
|
||||
$roleName = ! $supplierPortal && FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id'])
|
||||
? SystemAccess::SUPER_ADMIN : SystemAccess::USER;
|
||||
$role = Role::where('name', $roleName)->firstOrFail();
|
||||
$email = $this->normalizeEmail($profile['email'] ?? null);
|
||||
$email = $supplierPortal ? null : $this->normalizeEmail($profile['email'] ?? null);
|
||||
if (! $email || User::withTrashed()->where('email', $email)->exists()) {
|
||||
$email = 'ding-'.hash('sha256', $profile['union_id']).'@dingtalk.invalid';
|
||||
}
|
||||
@ -104,21 +126,25 @@ class DingTalkClosedAccess
|
||||
return null;
|
||||
}
|
||||
$account = User::create([
|
||||
'name' => Str::limit($profile['nickname'] ?: $roster['display_name'], 191, ''),
|
||||
'name' => Str::limit($profile['nickname'] ?: ($roster['display_name'] ?? '供应商人员'), 191, ''),
|
||||
'email' => $email,
|
||||
'password' => Str::random(64),
|
||||
'locale' => 'zh-CN',
|
||||
'enabled' => true,
|
||||
'landing_page' => 'dashboard',
|
||||
'landing_page' => $supplierPortal ? 'business.supplier-portal.home' : 'dashboard',
|
||||
'created_by' => 0,
|
||||
'created_from' => FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id'])
|
||||
? 'dingtalk_roster_limited' : 'dingtalk_roster',
|
||||
'created_from' => $supplierPortal
|
||||
? SupplierPortalAccess::CREATED_FROM
|
||||
: (FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id'])
|
||||
? 'dingtalk_roster_limited' : 'dingtalk_roster'),
|
||||
]);
|
||||
$account->roles()->attach($role->id);
|
||||
// Explicit designation controls the role; ordinary roster users get shell access.
|
||||
$account->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
|
||||
$account->companies()->attach($companies->first()->id);
|
||||
if ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) {
|
||||
if ($supplierPortal) {
|
||||
app(SupplierPortalAccess::class)->bind($account, (int) $companies->first()->id, $supplierRoster);
|
||||
} elseif ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) {
|
||||
Contact::create([
|
||||
'company_id' => $companies->first()->id, 'type' => Contact::EMPLOYEE_TYPE,
|
||||
'user_id' => $account->id, 'name' => $account->name, 'email' => $email,
|
||||
@ -131,7 +157,12 @@ class DingTalkClosedAccess
|
||||
'open_id' => $profile['open_id'], 'dingtalk_user_id' => $profile['dingtalk_user_id'],
|
||||
'nickname' => $profile['nickname'], 'bound_at' => now(),
|
||||
]);
|
||||
$this->upsertRequest($profile, DingTalkBindingRequest::APPROVED, 'finance_roster_provisioned', $account->id);
|
||||
$this->upsertRequest(
|
||||
$profile,
|
||||
DingTalkBindingRequest::APPROVED,
|
||||
$supplierPortal ? 'supplier_roster_provisioned' : 'finance_roster_provisioned',
|
||||
$account->id,
|
||||
);
|
||||
$this->audit('account_auto_created', 'success', $account->id, null, $profile['union_id'], $request);
|
||||
return $identity->load('user');
|
||||
}, 3);
|
||||
@ -165,9 +196,12 @@ class DingTalkClosedAccess
|
||||
|
||||
private function isEligible(?User $user): bool
|
||||
{
|
||||
return $user
|
||||
&& $user->enabled
|
||||
&& $user->withoutEvents(fn () => $user->companies()->enabled()->exists());
|
||||
return $user && $user->enabled && $this->enabledCompany($user) !== null;
|
||||
}
|
||||
|
||||
private function enabledCompany(User $user): ?Company
|
||||
{
|
||||
return $user->withoutEvents(fn () => $user->companies()->enabled()->first());
|
||||
}
|
||||
|
||||
private function upsertRequest(array $profile, string $status, string $reason, ?int $matchedUserId): void
|
||||
|
||||
194
app/Services/Auth/SupplierPortalAccess.php
Normal file
194
app/Services/Auth/SupplierPortalAccess.php
Normal file
@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\Auth\DingTalkIdentity;
|
||||
use App\Models\Auth\SupplierPortalIdentity;
|
||||
use App\Models\Auth\User;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
|
||||
/**
|
||||
* 钉钉企业组织中的供应商成员映射。
|
||||
*
|
||||
* 只有当前有效的钉钉企业员工、且其部门祖先精确对应唯一供应商主档名称时,
|
||||
* 才建立供应商门户身份。财务花名册不是供应商人员的准入前提。
|
||||
*/
|
||||
class SupplierPortalAccess
|
||||
{
|
||||
public const CREATED_FROM = 'dingtalk_supplier_portal';
|
||||
|
||||
public const PAGE_KEY = 'supplier-reconciliation/supplier-data';
|
||||
|
||||
public function __construct(
|
||||
private readonly UpstreamReadOnlyQueryFactory $queries,
|
||||
private readonly SupplierManagementMirrorRepository $suppliers,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function isSupplierUser(User $user): bool
|
||||
{
|
||||
return $user->created_from === self::CREATED_FROM;
|
||||
}
|
||||
|
||||
/** @return array{supplier_name:string,supplier_code:string,dingtalk_group_id:int,dingtalk_group_name:string}|null */
|
||||
public function resolve(string $userId, string $unionId = ''): ?array
|
||||
{
|
||||
$userId = trim($userId);
|
||||
$unionId = trim($unionId);
|
||||
if ($userId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = $this->queries->forSource('operations_replica');
|
||||
$employees = $query->select(
|
||||
'SELECT TOP (2) userid, unionid, name, active, dept_id_list
|
||||
FROM rpt.hr_employee WHERE userid = ?',
|
||||
[$userId],
|
||||
);
|
||||
if (count($employees) !== 1 || ! (bool) ($employees[0]['active'] ?? false)) {
|
||||
return null;
|
||||
}
|
||||
$employee = $employees[0];
|
||||
$employeeUnionId = trim((string) ($employee['unionid'] ?? ''));
|
||||
if ($employeeUnionId === '' || ($unionId !== '' && ! hash_equals($employeeUnionId, $unionId))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$departmentIds = $this->departmentIds($employee['dept_id_list'] ?? null);
|
||||
if ($departmentIds === [] || count($departmentIds) > 64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
|
||||
$ancestors = $query->select(
|
||||
'WITH lineage AS (
|
||||
SELECT Id, parent_id, name, 0 AS depth FROM rpt.hr_groups WHERE Id IN ('.$placeholders.')
|
||||
UNION ALL
|
||||
SELECT parent.Id, parent.parent_id, parent.name, child.depth + 1
|
||||
FROM rpt.hr_groups parent JOIN lineage child ON parent.Id = child.parent_id
|
||||
WHERE child.depth < 20
|
||||
) SELECT TOP (256) Id, parent_id, name, depth FROM lineage',
|
||||
$departmentIds,
|
||||
);
|
||||
if ($ancestors === [] || count($ancestors) >= 256
|
||||
|| array_filter($ancestors, static fn (array $row): bool => (int) ($row['depth'] ?? 0) >= 20)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$groupsByName = [];
|
||||
foreach ($ancestors as $ancestor) {
|
||||
$name = trim((string) ($ancestor['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$groupsByName[$name][] = $ancestor;
|
||||
}
|
||||
}
|
||||
if ($groupsByName === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$supplierRows = $this->suppliers->suppliersByNames(array_keys($groupsByName));
|
||||
$matchedSupplierNames = [];
|
||||
foreach ($supplierRows as $supplier) {
|
||||
$name = trim((string) ($supplier->scmname ?? ''));
|
||||
if ($name !== '' && isset($groupsByName[$name])) {
|
||||
$matchedSupplierNames[$name] = true;
|
||||
}
|
||||
}
|
||||
if (count($matchedSupplierNames) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$supplierName = (string) array_key_first($matchedSupplierNames);
|
||||
$matchingRows = array_values(array_filter(
|
||||
$supplierRows,
|
||||
static fn (object $supplier): bool => trim((string) ($supplier->scmname ?? '')) === $supplierName,
|
||||
));
|
||||
if (count($matchingRows) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($groupsByName[$supplierName], static fn (array $left, array $right): int =>
|
||||
(int) ($right['depth'] ?? 0) <=> (int) ($left['depth'] ?? 0));
|
||||
$group = $groupsByName[$supplierName][0];
|
||||
|
||||
return [
|
||||
'supplier_name' => $supplierName,
|
||||
'supplier_code' => trim((string) ($matchingRows[0]->scmcode ?? '')),
|
||||
'dingtalk_group_id' => (int) ($group['Id'] ?? 0),
|
||||
'dingtalk_group_name' => $supplierName,
|
||||
];
|
||||
}
|
||||
|
||||
public function bind(User $user, int $companyId, array $resolved): SupplierPortalIdentity
|
||||
{
|
||||
return SupplierPortalIdentity::query()->updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'company_id' => $companyId,
|
||||
'supplier_code' => $resolved['supplier_code'] ?? null,
|
||||
'supplier_name' => $resolved['supplier_name'],
|
||||
'dingtalk_group_id' => (int) $resolved['dingtalk_group_id'],
|
||||
'dingtalk_group_name' => $resolved['dingtalk_group_name'],
|
||||
'last_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function mappingFor(User $user): ?SupplierPortalIdentity
|
||||
{
|
||||
if (! self::isSupplierUser($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SupplierPortalIdentity::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('company_id', company_id())
|
||||
->first();
|
||||
}
|
||||
|
||||
public function supplierFor(User $user): ?string
|
||||
{
|
||||
$mapping = $this->mappingFor($user);
|
||||
$name = trim((string) ($mapping?->supplier_name ?? ''));
|
||||
|
||||
return $name !== '' ? $name : null;
|
||||
}
|
||||
|
||||
public function revalidate(User $user): ?SupplierPortalIdentity
|
||||
{
|
||||
if (! self::isSupplierUser($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dingTalk = DingTalkIdentity::query()->where('user_id', $user->id)->first();
|
||||
$company = $user->withoutEvents(fn () => $user->companies()->enabled()->first());
|
||||
if (! $dingTalk || ! $company) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resolved = $this->resolve(
|
||||
(string) $dingTalk->dingtalk_user_id,
|
||||
(string) $dingTalk->union_id,
|
||||
);
|
||||
|
||||
return $resolved ? $this->bind($user, (int) $company->id, $resolved) : null;
|
||||
}
|
||||
|
||||
private function departmentIds(mixed $value): array
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return [];
|
||||
}
|
||||
$parts = json_decode($value, true);
|
||||
if (! is_array($parts)) {
|
||||
$parts = preg_split('/[\[\]\/>,,;;||\s]+/u', $value, -1, PREG_SPLIT_NO_EMPTY);
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $id): string => is_scalar($id) ? trim((string) $id) : '',
|
||||
is_array($parts) ? $parts : [],
|
||||
), static fn (string $id): bool => ctype_digit($id) && (int) $id > 0));
|
||||
}
|
||||
}
|
||||
@ -35,6 +35,7 @@ final class CourierQuoteChangeService
|
||||
'change_type' => $data['change_type'],
|
||||
'status' => CourierQuoteChange::STATUS_DRAFT,
|
||||
'effective_date' => $data['effective_date'],
|
||||
'end_date' => ($data['end_date'] ?? null) ?: null,
|
||||
'is_retrospective' => $retrospective,
|
||||
'recalculation_status' => $retrospective
|
||||
? CourierQuoteChange::RECALCULATION_PENDING
|
||||
@ -66,6 +67,7 @@ final class CourierQuoteChangeService
|
||||
$change->update([
|
||||
'change_type' => $data['change_type'],
|
||||
'effective_date' => $data['effective_date'],
|
||||
'end_date' => $change->isStockQuote() ? (($data['end_date'] ?? null) ?: null) : $change->end_date,
|
||||
'is_retrospective' => $retrospective,
|
||||
'recalculation_status' => $retrospective
|
||||
? CourierQuoteChange::RECALCULATION_PENDING
|
||||
@ -139,10 +141,19 @@ final class CourierQuoteChangeService
|
||||
$source = $this->findSourceQuote($change->source_quote_id);
|
||||
|
||||
return DB::transaction(function () use ($change, $source, $userId, $historicalDocumentsConfirmed): CourierQuoteChange {
|
||||
$change = CourierQuoteChange::query()->lockForUpdate()->findOrFail($change->id);
|
||||
if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) {
|
||||
throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过,请刷新状态。']);
|
||||
}
|
||||
if ($change->isStockQuote()) {
|
||||
\App\Models\Common\Company::whereKey(company_id())->lockForUpdate()->firstOrFail();
|
||||
$entry = CourierStockQuoteEntry::findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'));
|
||||
app(CourierStockQuoteEntryService::class)->assertPeriod(['code' => $entry->courier_expcode, 'name' => $entry->courier_name], $entry->quote_type, $change->effective_date->toDateString(), $change->end_date?->toDateString(), $entry->id);
|
||||
}
|
||||
$hasHistoricalFiles = $change->isStockQuote()
|
||||
&& collect(CourierStockQuoteEntry::query()->findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'))->files)->contains('kind', 'signed');
|
||||
if ($change->isStockQuote() && $historicalDocumentsConfirmed && ! $hasHistoricalFiles) {
|
||||
throw ValidationException::withMessages(['stock_signature_verified' => '尚未上传历史签署资料,请补充资料或保留待确认/待补签状态。']);
|
||||
throw ValidationException::withMessages(['stock_signature_verified' => '尚未上传历史签署资料,请补充资料或取消历史资料核验;不影响存量报价审核生效。']);
|
||||
}
|
||||
$historicalSigned = $hasHistoricalFiles && $historicalDocumentsConfirmed;
|
||||
$values = $source['values'];
|
||||
@ -182,7 +193,7 @@ final class CourierQuoteChangeService
|
||||
'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'formulas_json' => json_encode($formulas, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'status' => 'approved',
|
||||
'signature_status' => $change->isStockQuote() ? ($historicalSigned ? '历史签署资料已归档' : '待确认/待补签') : '未签署',
|
||||
'signature_status' => $change->isStockQuote() ? ($historicalSigned ? '历史签署资料已归档' : '存量报价,审核生效(无需签章)') : '未签署',
|
||||
'created_by' => $change->created_by,
|
||||
'approved_by' => $userId,
|
||||
]);
|
||||
|
||||
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);
|
||||
|
||||
@ -370,7 +370,7 @@ final class CourierRequoteService
|
||||
{
|
||||
return $versions
|
||||
->filter(static fn (CourierQuoteVersion $version): bool => ! $version->isVoided()
|
||||
&& self::isSignedVersion($version)
|
||||
&& ($version->isApprovedStockQuote() || self::isSignedVersion($version))
|
||||
&& ($includeTemporary || ! $version->isTemporary())
|
||||
&& $version->coversDate($date))
|
||||
->sortBy([
|
||||
|
||||
@ -77,7 +77,7 @@ final class CourierStockQuoteBatchService
|
||||
], true)));
|
||||
$courier = count($matches) === 1 ? $matches[0] : ['code' => '', 'name' => trim($sheet['sheet_name'])];
|
||||
$code = $courier['code'] ?? '';
|
||||
$duplicate = $this->entries->isDuplicate($courier, $manifest['defaults']['quote_type'], $existing);
|
||||
$duplicate = $this->entries->isDuplicate($courier, $manifest['defaults']['quote_type'], $existing, ($manifest['defaults']['effective_date'] ?? null) ?: null, ($manifest['defaults']['end_date'] ?? null) ?: null);
|
||||
|
||||
return [...$sheet, 'courier_expcode' => $code, 'courier_name' => $courier['name'], 'duplicate' => $duplicate,
|
||||
'match_status' => $sheet['error'] ?: ($duplicate ? '已录入,默认跳过' : ($code !== '' ? '已匹配' : (count($matches) > 1 ? '名称对应多条资料,请确认发货名称;按名称保存' : '可手填名称,请与发货数据一致'))),
|
||||
@ -110,10 +110,12 @@ final class CourierStockQuoteBatchService
|
||||
$rules = $data['rule_groups'][$row['rule_group'] ?? -1]['rules'] ?? '';
|
||||
$metadata = [
|
||||
'quote_type' => trim($data['quote_type']), 'effective_date' => ($row['effective_date'] ?? '') ?: ($data['effective_date'] ?? ''),
|
||||
'end_date' => ($row['end_date'] ?? null) ?: (($data['end_date'] ?? null) ?: null),
|
||||
'billing_rules' => trim($rules), 'summary' => $data['summary'],
|
||||
];
|
||||
$validation = Validator::make($metadata, [
|
||||
'effective_date' => ['required', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
|
||||
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
|
||||
], ['effective_date.required' => '请填写实际执行日期或设置共用日期。', 'billing_rules.required' => '请选择并填写计费规则。']);
|
||||
$reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null);
|
||||
@ -125,9 +127,9 @@ final class CourierStockQuoteBatchService
|
||||
}
|
||||
}
|
||||
if ($courier !== null) {
|
||||
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $existing) ? '此快递的同类报价已录入,请取消选择;后续调价使用变价。' : null;
|
||||
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $identities) ? '与本批其他工作表填写了同一家快递,请检查名称或取消选择。' : null;
|
||||
$identities[] = [...$courier, 'quote_type' => $metadata['quote_type']];
|
||||
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $existing, $metadata['effective_date'], $metadata['end_date']) ? '与已录入的同类报价执行期间重叠,请核对起止日期。' : null;
|
||||
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $identities, $metadata['effective_date'], $metadata['end_date']) ? '与本批同一家快递的执行期间重叠,请核对起止日期。' : null;
|
||||
$identities[] = [...$courier, 'quote_type' => $metadata['quote_type'], 'effective_date' => $metadata['effective_date'], 'end_date' => $metadata['end_date']];
|
||||
}
|
||||
if ($reason !== null) {
|
||||
$errors['entries.' . $index] = '「' . ($sheet['sheet_name'] ?? $index) . '」' . $reason;
|
||||
|
||||
@ -73,17 +73,20 @@ final class CourierStockQuoteEntryService
|
||||
|
||||
public function existingIdentities(?int $exceptId = null): array
|
||||
{
|
||||
return CourierStockQuoteEntry::withTrashed()->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId))
|
||||
->get(['courier_expcode', 'courier_name', 'quote_type'])
|
||||
->map(fn ($entry) => ['code' => $entry->courier_expcode, 'name' => $entry->courier_name, 'quote_type' => $entry->quote_type])->all();
|
||||
return CourierStockQuoteEntry::withTrashed()->with(['change:id,company_id', 'change.version:id,company_id,source_change_id,status'])->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId))
|
||||
->get(['id', 'company_id', 'source_change_id', 'courier_expcode', 'courier_name', 'quote_type', 'effective_date', 'end_date'])->filter(fn ($entry) => !$entry->change?->version?->isVoided())
|
||||
->map(fn ($entry) => ['code' => $entry->courier_expcode, 'name' => $entry->courier_name, 'quote_type' => $entry->quote_type,
|
||||
'effective_date' => $entry->effective_date?->toDateString(), 'end_date' => $entry->end_date?->toDateString()])->all();
|
||||
}
|
||||
|
||||
public function isDuplicate(array $courier, string $quoteType, array $existing): bool
|
||||
public function isDuplicate(array $courier, string $quoteType, array $existing, ?string $start = null, ?string $end = null): bool
|
||||
{
|
||||
foreach ($existing as $entry) {
|
||||
if ($entry['quote_type'] !== $quoteType) {
|
||||
continue;
|
||||
}
|
||||
if ($start !== null && isset($entry['effective_date'])
|
||||
&& ($start > ($entry['end_date'] ?? '9999-12-31') || ($end ?? '9999-12-31') < $entry['effective_date'])) continue;
|
||||
if (filled($courier['code']) && filled($entry['code'])) {
|
||||
if ($courier['code'] === $entry['code']) {
|
||||
return true;
|
||||
@ -96,6 +99,18 @@ final class CourierStockQuoteEntryService
|
||||
return false;
|
||||
}
|
||||
|
||||
public function assertPeriod(array $courier, string $quoteType, string $start, ?string $end, ?int $exceptId = null): void
|
||||
{
|
||||
if ($end !== null && $end < $start) throw ValidationException::withMessages(['end_date' => '截止日期不能早于开始日期。']);
|
||||
if ($this->isDuplicate($courier, $quoteType, $this->existingIdentities($exceptId), $start, $end)) $this->duplicateError();
|
||||
}
|
||||
|
||||
private function lockCompany(): void
|
||||
{
|
||||
// 同公司导入/改期串行,覆盖空集合及无编号快递,防止并发导入重叠时期。
|
||||
\App\Models\Common\Company::whereKey(company_id())->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
|
||||
/** 保留公式和 Excel 缓存值,不执行上传文件中的公式或外部引用。 */
|
||||
public function readWorkbook(UploadedFile $file, ?string $sheetName): array
|
||||
{
|
||||
@ -203,9 +218,7 @@ final class CourierStockQuoteEntryService
|
||||
{
|
||||
$data['quote_type'] = trim($data['quote_type']);
|
||||
$courier = $this->resolveCourier($data);
|
||||
if ($this->isDuplicate($courier, $data['quote_type'], $this->existingIdentities())) {
|
||||
$this->duplicateError();
|
||||
}
|
||||
$this->assertPeriod($courier, $data['quote_type'], $data['effective_date'], ($data['end_date'] ?? null) ?: null);
|
||||
$matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null);
|
||||
|
||||
return $this->createPrepared($data, $userId, $courier, $matrix);
|
||||
@ -217,19 +230,21 @@ final class CourierStockQuoteEntryService
|
||||
$savedPaths = [];
|
||||
try {
|
||||
return DB::transaction(function () use ($data, $userId, $courier, $matrix, $sourceFiles, &$savedPaths): CourierStockQuoteEntry {
|
||||
$this->lockCompany();
|
||||
$this->assertPeriod($courier, $data['quote_type'], $data['effective_date'], ($data['end_date'] ?? null) ?: null);
|
||||
$entry = CourierStockQuoteEntry::create([
|
||||
'company_id' => company_id(), 'courier_expcode' => $courier['code'], 'courier_name' => $courier['name'],
|
||||
'courier_name_key' => blank($courier['code']) ? hash('sha256', mb_strtolower(trim($courier['name']))) : null,
|
||||
'quote_type' => $data['quote_type'], 'sheet_name' => $matrix['sheet_name'], 'source_range' => $matrix['source_range'],
|
||||
'values_json' => json_encode($matrix['values'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'formulas_json' => json_encode($matrix['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
'files' => [], 'created_by' => $userId,
|
||||
'files' => [], 'created_by' => $userId, 'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null,
|
||||
]);
|
||||
$files = [...$sourceFiles, ...$this->storeFiles($entry, $data, $savedPaths)];
|
||||
$entry->update(['files' => $files]);
|
||||
$change = $this->changes->create([
|
||||
'source_quote_id' => 'STOCK-' . $entry->id, 'change_type' => '存量报价',
|
||||
'effective_date' => $data['effective_date'], 'summary' => $data['summary'], 'items' => [],
|
||||
'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null, 'summary' => $data['summary'], 'items' => [],
|
||||
'quote_payload' => ['stock_quote_entry_id' => $entry->id, 'billing_rules' => $data['billing_rules']],
|
||||
'proposed_values_json' => $entry->values_json,
|
||||
], $userId);
|
||||
@ -253,14 +268,13 @@ final class CourierStockQuoteEntryService
|
||||
$courier = ['code' => $entry->courier_expcode, 'name' => $entry->courier_name];
|
||||
if (isset($data['courier_name']) && trim($data['courier_name']) !== $entry->courier_name) {
|
||||
$courier = $this->resolveCourier(['courier_name' => $data['courier_name']]);
|
||||
if ($this->isDuplicate($courier, $entry->quote_type, $this->existingIdentities($entry->id))) {
|
||||
$this->duplicateError();
|
||||
}
|
||||
}
|
||||
$matrix = isset($data['source_quote']) ? $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null) : null;
|
||||
$savedPaths = [];
|
||||
try {
|
||||
DB::transaction(function () use ($entry, $data, $userId, $matrix, $courier, &$savedPaths): void {
|
||||
$this->lockCompany();
|
||||
$this->assertPeriod($courier, $entry->quote_type, $data['effective_date'], ($data['end_date'] ?? null) ?: null, $entry->id);
|
||||
$files = $entry->files;
|
||||
if ($matrix !== null) {
|
||||
foreach ($files as &$file) {
|
||||
@ -279,6 +293,7 @@ final class CourierStockQuoteEntryService
|
||||
'courier_name' => $courier['name'], 'courier_expcode' => $courier['code'],
|
||||
'courier_name_key' => blank($courier['code']) ? hash('sha256', mb_strtolower(trim($courier['name']))) : null,
|
||||
'files' => [...$files, ...$this->storeFiles($entry, $data, $savedPaths)],
|
||||
'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null,
|
||||
])->save();
|
||||
$entry->change->update(['carrier_name' => $courier['name'], 'source_sheet_name' => $entry->sheet_name, 'proposed_values_json' => $entry->values_json]);
|
||||
$this->changes->update($entry->change, [...$data, 'change_type' => '存量报价', 'items' => []], $userId);
|
||||
@ -311,6 +326,28 @@ final class CourierStockQuoteEntryService
|
||||
|
||||
private function duplicateError(): never
|
||||
{
|
||||
throw ValidationException::withMessages(['courier_name' => '该快递的此类存量报价已录入,请到报价历史继续处理;后续调价请使用变价。']);
|
||||
throw ValidationException::withMessages(['courier_name' => '该快递的同类报价执行期间重叠,请核对起止日期;已有长期报价请先设置截止日期,不覆盖原报价。']);
|
||||
}
|
||||
|
||||
public function setPeriodEnd(CourierStockQuoteEntry $entry, string $end, string $reason, int $userId): void
|
||||
{
|
||||
abort_unless($entry->company_id === (int) company_id(), 404);
|
||||
DB::transaction(function () use ($entry, $end, $reason, $userId): void {
|
||||
$this->lockCompany();
|
||||
$entry->refresh()->load('change.version');
|
||||
$version = $entry->change?->version;
|
||||
if (!$version?->isApprovedStockQuote()) throw ValidationException::withMessages(['end_date' => '仅已审核且未作废的存量报价可设置截止日期;草稿请从报价历史编辑。']);
|
||||
$this->assertPeriod(['code' => $entry->courier_expcode, 'name' => $entry->courier_name], $entry->quote_type, $entry->effective_date->toDateString(), $end, $entry->id);
|
||||
$old = $entry->end_date?->toDateString();
|
||||
$entry->update(['end_date' => $end]);
|
||||
$entry->change->update(['end_date' => $end]);
|
||||
$version->update(['end_date' => $end]);
|
||||
\App\Models\Business\CourierQuoteChangeEvent::create([
|
||||
'company_id' => company_id(), 'quote_change_id' => $entry->source_change_id, 'event_type' => 'period_end_updated',
|
||||
'from_status' => $entry->change->status, 'to_status' => $entry->change->status, 'created_by' => $userId,
|
||||
'note' => '设置执行截止日期:' . $reason,
|
||||
'details_json' => json_encode(['previous_end_date' => $old, 'end_date' => $end], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,21 +172,29 @@ 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(),
|
||||
'end_date' => $version->end_date?->toDateString(),
|
||||
'period_label' => $version->isTemporary() ? $version->periodLabel() : null,
|
||||
'period_label' => $version->end_date !== null ? $version->periodLabel() : null,
|
||||
'group_key' => (filled($version->courier_expcode) ? 'code:' . $version->courier_expcode : 'name:' . $version->company_name) . ':' . $version->quote_type,
|
||||
'stock_entry_id' => $version->change?->isStockQuote() ? data_get($version->change->quote_payload, 'stock_quote_entry_id') : null,
|
||||
'eligible' => !$version->isVoided() && $this->isExecutable($version),
|
||||
'is_stock' => (bool) $version->change?->isStockQuote(),
|
||||
'period_end_url' => $version->isApprovedStockQuote() && in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::ADMIN, \App\Utilities\SystemAccess::SUPER_ADMIN], true)
|
||||
? route('business.courier-stock-quotes.period-end', data_get($version->change->quote_payload, 'stock_quote_entry_id')) : null,
|
||||
'source_range' => $version->source_range ?? '',
|
||||
'row_count' => count($values),
|
||||
'column_count' => $columnCount,
|
||||
'formula_count' => $this->formulaCount($formulas),
|
||||
'status' => $this->versionLifecycleStatus($version),
|
||||
'signature_status' => $version->signature_status,
|
||||
'signature_status' => $version->signatureLabel(),
|
||||
'values' => $values,
|
||||
'formulas' => $formulas,
|
||||
'source_change_id' => $version->source_change_id,
|
||||
'signed_files' => $this->signedFiles($version),
|
||||
'stock_originals' => $this->stockOriginals($version),
|
||||
'signed_at' => $version->signature?->completed_at?->format('Y-m-d H:i')
|
||||
?? $version->signature?->archived_at?->format('Y-m-d H:i'),
|
||||
];
|
||||
@ -205,6 +230,24 @@ final class QuoteManagementSnapshot
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function stockOriginals(CourierQuoteVersion $version): array
|
||||
{
|
||||
if (!$version->change?->isStockQuote() || !($entry = $version->change->stockEntry)) return [];
|
||||
$files = [];
|
||||
foreach ($entry->files as $index => $file) {
|
||||
if (($file['kind'] ?? '') !== 'signed') continue;
|
||||
// 早期手动上传未记录 MIME;入口按扩展名提供,响应仍按实际 MIME 白名单校验。
|
||||
$previewable = isset($file['mime_type'])
|
||||
? in_array($file['mime_type'], ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true)
|
||||
: in_array(strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)), ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'], true);
|
||||
$files[] = [
|
||||
'name' => $file['name'], 'previewable' => $previewable,
|
||||
'url' => route('business.courier-stock-quotes.download', [$entry, $index, 'preview' => $previewable ? 1 : 0]),
|
||||
];
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function decodeMatrix(?string $json): array
|
||||
{
|
||||
if ($json === null || trim($json) === '') {
|
||||
@ -223,8 +266,9 @@ final class QuoteManagementSnapshot
|
||||
if ($version->isVoided()) {
|
||||
return '已作废';
|
||||
}
|
||||
if ($version->end_date?->lt(today()) && $this->isExecutable($version)) return '已到期';
|
||||
|
||||
if (! $this->isSigned($version)) {
|
||||
if (! $this->isExecutable($version)) {
|
||||
return '待生效(待签章)';
|
||||
}
|
||||
|
||||
@ -235,10 +279,10 @@ final class QuoteManagementSnapshot
|
||||
return $version->isTemporary() ? '生效中(临时)' : '生效中';
|
||||
}
|
||||
|
||||
/** 回签单已归档(以签署记录为准,版本上的文字状态可能滞后)。 */
|
||||
private function isSigned(CourierQuoteVersion $version): bool
|
||||
/** 存量报价审核生效;其他报价仍要求回签归档。 */
|
||||
private function isExecutable(CourierQuoteVersion $version): bool
|
||||
{
|
||||
return in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true)
|
||||
return $version->isApprovedStockQuote() || in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true)
|
||||
|| $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED;
|
||||
}
|
||||
|
||||
@ -261,7 +305,7 @@ final class QuoteManagementSnapshot
|
||||
: 'name:' . $version->company_name)) as $group) {
|
||||
$current = CourierRequoteService::resolveAt($group, $today);
|
||||
$base = CourierRequoteService::resolveAt($group, $today, false);
|
||||
$started = $group->filter(fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $this->isSigned($version)
|
||||
$started = $group->filter(fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $this->isExecutable($version)
|
||||
&& $version->effective_date !== null && $version->effective_date->lte($today));
|
||||
foreach ($started as $version) {
|
||||
if ($current !== null && $version->id === $current->id) {
|
||||
@ -270,8 +314,8 @@ final class QuoteManagementSnapshot
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($version->isTemporary() && $version->end_date->lt($today)) {
|
||||
$result[$version->id] = ['status' => '已到期', 'status_note' => '临时价 ' . $version->periodLabel()];
|
||||
if ($version->end_date?->lt($today)) {
|
||||
$result[$version->id] = ['status' => '已到期', 'status_note' => ($version->isTemporary() ? '临时价 ' : '执行期间 ') . $version->periodLabel()];
|
||||
} elseif ($base !== null && $version->id === $base->id && $current?->isTemporary()) {
|
||||
$result[$version->id] = ['status' => '暂停中', 'status_note' => $current->version_code . ' 临时价执行中,' . $current->end_date->copy()->addDay()->toDateString() . ' 起恢复'];
|
||||
} elseif ($current !== null) {
|
||||
|
||||
48
app/Utilities/FinanceLedgerAccess.php
Normal file
48
app/Utilities/FinanceLedgerAccess.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
if (SystemAccess::roleName($user) === SystemAccess::ADMIN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$roles = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string) config('finance_ledger.mapping_editor_roles', '财务台账管理员')),
|
||||
)));
|
||||
|
||||
return in_array(SystemAccess::roleName($user), $roles, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户状态、账户类型与账户映射使用同一组财务台账编辑角色。
|
||||
*/
|
||||
public static function canEditAccountStatus(?User $user = null): bool
|
||||
{
|
||||
return self::canEditMapping($user);
|
||||
}
|
||||
|
||||
public static function canEditAccountType(?User $user = null): bool
|
||||
{
|
||||
return self::canEditMapping($user);
|
||||
}
|
||||
}
|
||||
65
app/Utilities/FinanceLedgerAccountShouldKeep.php
Normal file
65
app/Utilities/FinanceLedgerAccountShouldKeep.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
||||
class FinanceLedgerAccountShouldKeep
|
||||
{
|
||||
public const YES = '是';
|
||||
|
||||
public const NO = '否';
|
||||
|
||||
public const OPTIONS = [self::YES, self::NO];
|
||||
|
||||
public static function normalize(?string $value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$compact = mb_strtoupper((string) preg_replace('/\s+/u', '', $value));
|
||||
if (in_array($compact, ['是', '保留', '需要保留', '需要', 'Y', 'YES', 'TRUE', '1'], true)) {
|
||||
return self::YES;
|
||||
}
|
||||
if (in_array($compact, ['否', '不保留', '不需要保留', '不需要', 'N', 'NO', 'FALSE', '0'], true)) {
|
||||
return self::NO;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function display(?string $explicitValue, ?string $companyPlan): string
|
||||
{
|
||||
$normalized = self::normalize($explicitValue);
|
||||
if ($normalized !== '') {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$plan = trim((string) $companyPlan);
|
||||
if ($plan === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return str_contains($plan, '保留') ? self::YES : self::NO;
|
||||
}
|
||||
|
||||
public static function applyUnretainedCondition(
|
||||
Builder $query,
|
||||
string $explicitColumn = 'account_should_keep',
|
||||
string $companyPlanColumn = 'company_plan',
|
||||
): Builder {
|
||||
return $query->where(function (Builder $keep) use ($explicitColumn, $companyPlanColumn): void {
|
||||
$keep->where($explicitColumn, self::NO)
|
||||
->orWhere(function (Builder $inferred) use ($explicitColumn, $companyPlanColumn): void {
|
||||
$inferred->where(function (Builder $blank) use ($explicitColumn): void {
|
||||
$blank->whereNull($explicitColumn)->orWhere($explicitColumn, '');
|
||||
})
|
||||
->whereNotNull($companyPlanColumn)
|
||||
->where($companyPlanColumn, '<>', '')
|
||||
->where($companyPlanColumn, 'not like', '%保留%');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
30
app/Utilities/FinanceLedgerAccountStatus.php
Normal file
30
app/Utilities/FinanceLedgerAccountStatus.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
final class FinanceLedgerAccountStatus
|
||||
{
|
||||
public const NORMAL = 'normal';
|
||||
public const ONLINE_BANKING_ABNORMAL = 'online_banking_abnormal';
|
||||
public const LIMITED = 'limited';
|
||||
public const BUSINESS_DEREGISTERED = 'business_deregistered';
|
||||
|
||||
public const OPTIONS = [
|
||||
self::NORMAL => '正常',
|
||||
self::ONLINE_BANKING_ABNORMAL => '网银异常',
|
||||
self::LIMITED => '限额',
|
||||
self::BUSINESS_DEREGISTERED => '工商注销',
|
||||
];
|
||||
|
||||
public static function normalize(?string $status): string
|
||||
{
|
||||
$status = trim((string) $status);
|
||||
|
||||
return array_key_exists($status, self::OPTIONS) ? $status : self::NORMAL;
|
||||
}
|
||||
|
||||
public static function label(?string $status): string
|
||||
{
|
||||
return self::OPTIONS[self::normalize($status)];
|
||||
}
|
||||
}
|
||||
54
app/Utilities/FinanceLedgerAccountType.php
Normal file
54
app/Utilities/FinanceLedgerAccountType.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
final class FinanceLedgerAccountType
|
||||
{
|
||||
public const ALIPAY = '支付宝';
|
||||
|
||||
public const BANK_ACCOUNT_TYPES = ['公户', '个人卡', '网商银行'];
|
||||
|
||||
public const DEFAULT_OPTIONS = [
|
||||
'个人卡',
|
||||
'公户',
|
||||
self::ALIPAY,
|
||||
'网商银行',
|
||||
];
|
||||
|
||||
public static function options(array ...$groups): array
|
||||
{
|
||||
$types = self::DEFAULT_OPTIONS;
|
||||
foreach ($groups as $group) {
|
||||
foreach ($group as $type) {
|
||||
$type = trim((string) $type);
|
||||
if ($type !== '') {
|
||||
$types[] = $type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$types = array_values(array_unique($types));
|
||||
sort($types, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
public static function last4(?string $accountType, ?string $accountNo, ?string $fallback = null): string
|
||||
{
|
||||
$type = trim((string) $accountType);
|
||||
if ($type === self::ALIPAY) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (in_array($type, self::BANK_ACCOUNT_TYPES, true)) {
|
||||
$digits = preg_replace('/\D+/', '', (string) $accountNo);
|
||||
if (strlen((string) $digits) >= 4) {
|
||||
return substr((string) $digits, -4);
|
||||
}
|
||||
}
|
||||
|
||||
$fallback = trim((string) $fallback);
|
||||
|
||||
return $fallback !== '' ? $fallback : '/';
|
||||
}
|
||||
}
|
||||
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'));
|
||||
}
|
||||
}
|
||||
140
app/Utilities/SidebarMenu.php
Normal file
140
app/Utilities/SidebarMenu.php
Normal file
@ -0,0 +1,140 @@
|
||||
<?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' => [
|
||||
['financial-operations', 'ledger', '财务台账', 'menu_book'],
|
||||
['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;
|
||||
}
|
||||
}
|
||||
@ -5,11 +5,14 @@ namespace App\Utilities;
|
||||
use App\Models\Auth\User;
|
||||
use App\Models\Auth\UserAccessGroup;
|
||||
use App\Services\Auth\FinanceRoster;
|
||||
use App\Services\Auth\SupplierPortalAccess;
|
||||
|
||||
class SidebarPermissions
|
||||
{
|
||||
private const LEGACY_PAGE_KEYS = [
|
||||
'refunds/small-payment-work-orders' => 'financial-operations/small-payment-work-orders',
|
||||
'financial-ledger' => 'financial-operations/ledger',
|
||||
'financial-ledger/ledger' => 'financial-operations/ledger',
|
||||
];
|
||||
|
||||
private const FINAL_SECTION_KEYS = [
|
||||
@ -43,6 +46,10 @@ class SidebarPermissions
|
||||
public static function allowedKeys(User $user): ?array
|
||||
{
|
||||
// Limited identities cannot inherit full navigation from roles, groups or user overrides.
|
||||
if (SupplierPortalAccess::isSupplierUser($user)) {
|
||||
return [SupplierPortalAccess::PAGE_KEY];
|
||||
}
|
||||
|
||||
if (self::hasLimitedDingTalkAccess($user)) {
|
||||
return config('dingtalk.limited_login_page_keys', []);
|
||||
}
|
||||
@ -75,7 +82,8 @@ class SidebarPermissions
|
||||
|
||||
public static function hasLimitedDingTalkAccess(User $user): bool
|
||||
{
|
||||
return $user->created_from === 'dingtalk_roster_limited'
|
||||
return SupplierPortalAccess::isSupplierUser($user)
|
||||
|| $user->created_from === 'dingtalk_roster_limited'
|
||||
|| (config('dingtalk.limited_login_user_ids', []) !== []
|
||||
&& FinanceRoster::isLimitedLoginUser((string) $user->dingtalkIdentity?->dingtalk_user_id));
|
||||
}
|
||||
|
||||
@ -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:
|
||||
@ -93,6 +94,9 @@ services:
|
||||
pull_policy: never
|
||||
container_name: caiwu_app
|
||||
restart: unless-stopped
|
||||
# Web requests and small scheduled jobs have their own memory boundary.
|
||||
mem_limit: 2g
|
||||
memswap_limit: 2g
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@ -100,6 +104,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 +126,36 @@ services:
|
||||
retries: 10
|
||||
start_period: 45s
|
||||
|
||||
# Heavy CLI jobs use a separate cgroup from Apache.
|
||||
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:
|
||||
FINANCE_BATCH_CONTAINER: "1"
|
||||
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: ${FINANCE_BATCH_MEMORY_LIMIT:-3g}
|
||||
memswap_limit: ${FINANCE_BATCH_MEMORY_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' => '种菜损耗'],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -685,6 +687,7 @@ $navigation = [
|
||||
'icon' => 'local_shipping',
|
||||
'pages' => [
|
||||
['slug' => 'supplier-management', 'title' => '供应商管理'],
|
||||
['slug' => 'accessory-freight-deductions', 'title' => '新河北仓、曹县仓配件运费扣除明细'],
|
||||
['slug' => 'product-prices', 'title' => '商品价格'],
|
||||
['slug' => 'payment-reconciliation', 'title' => '货款对账'],
|
||||
['slug' => 'return-reconciliation', 'title' => '退货对账'],
|
||||
@ -694,6 +697,7 @@ $navigation = [
|
||||
['slug' => 'advance-details', 'title' => '借支明细'],
|
||||
['slug' => 'sample-deductions', 'title' => '样品扣款'],
|
||||
['slug' => 'other-adjustments', 'title' => '其他扣款补款'],
|
||||
['slug' => 'manufacturer-bookkeeping', 'title' => '厂家记账'],
|
||||
[
|
||||
'slug' => 'unpriced-details',
|
||||
'title' => '未定价明细',
|
||||
@ -818,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' => '平台'],
|
||||
@ -830,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'],
|
||||
@ -875,6 +881,7 @@ $navigation = [
|
||||
['slug' => 'bookkeeping-details', 'title' => '记账明细'],
|
||||
['slug' => 'non-operating-expenses', 'title' => '营业外支出'],
|
||||
['slug' => 'financial-analysis', 'title' => '财务经营分析表'],
|
||||
['slug' => 'ledger', 'title' => '财务台账', 'show_in_section_menu' => false],
|
||||
],
|
||||
],
|
||||
[
|
||||
@ -886,7 +893,7 @@ $navigation = [
|
||||
['slug' => 'preview-statistics', 'title' => '预览统计'],
|
||||
[
|
||||
'slug' => 'process-statistics',
|
||||
'title' => '流程统计',
|
||||
'title' => '店铺流程',
|
||||
'directory' => [
|
||||
'icon' => 'timeline',
|
||||
'status' => '共享镜像',
|
||||
|
||||
7
config/finance_batch.php
Normal file
7
config/finance_batch.php
Normal file
@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'container' => (bool) env('FINANCE_BATCH_CONTAINER', false),
|
||||
// The host runner and application share this registry.
|
||||
'import_commands' => json_decode(file_get_contents(base_path('deploy/finance-batch-imports.json')), true, 512, JSON_THROW_ON_ERROR),
|
||||
];
|
||||
29
config/finance_ledger.php
Normal file
29
config/finance_ledger.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?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'),
|
||||
|
||||
// 台账上游查询允许更长的超时;普通页面仍使用共享连接的 15 秒。
|
||||
'query_timeout' => (int) env('FINANCE_LEDGER_QUERY_TIMEOUT', 120),
|
||||
|
||||
// 具备账户映射写权限的角色;超级管理员始终可写。
|
||||
'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' => '固定资产报废或出售流程',
|
||||
],
|
||||
];
|
||||
143
config/order_after_sales_cost.php
Normal file
143
config/order_after_sales_cost.php
Normal file
@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 订单售后费用明细(聚水潭「订单售后费用数据」导出)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 聚水潭导出文件是中文表头,这里把表头映射成页面字段键,导入时按这个映射入库。
|
||||
| 未列出的表头会忽略;新增表头时补在这里即可。
|
||||
*/
|
||||
|
||||
return [
|
||||
'headers' => [
|
||||
'内部订单号' => 'internal_order_no',
|
||||
'线上订单号' => 'online_order_no',
|
||||
'子订单号' => 'sub_order_no',
|
||||
'售后单号' => 'after_sales_no',
|
||||
'支付时间' => 'pay_time',
|
||||
'订单完成时间' => 'order_complete_time',
|
||||
'发货时间' => 'send_time',
|
||||
'订单时间' => 'order_time',
|
||||
'店铺名称' => 'shop_name',
|
||||
'店铺编码' => 'shop_code',
|
||||
'商品编码' => 'product_code',
|
||||
'款式编码' => 'style_code',
|
||||
'款式编码(参考)' => 'style_code',
|
||||
'店铺商品编码' => 'shop_product_code',
|
||||
'店铺款式编码' => 'shop_style_code',
|
||||
'链接' => 'product_link',
|
||||
"链接\u{3000}" => 'product_link',
|
||||
'品牌' => 'brand',
|
||||
'分类' => 'category',
|
||||
'虚拟分类' => 'virtual_category',
|
||||
'颜色及规格' => 'color_spec',
|
||||
'商品名称' => 'product_name',
|
||||
'唯一码' => 'unique_code',
|
||||
'商品数量' => 'product_quantity',
|
||||
'商品金额' => 'product_amount',
|
||||
'商品成本' => 'product_cost',
|
||||
'成本来源' => 'cost_source',
|
||||
'其中:红冲订单成本' => 'reversal_order_cost',
|
||||
'手动调整收入' => 'manual_adjust_income',
|
||||
'手动调整成本' => 'manual_adjust_cost',
|
||||
'商家优惠' => 'merchant_discount',
|
||||
'运费收入' => 'freight_income',
|
||||
'付款金额' => 'payment_amount',
|
||||
'其中:平台优惠' => 'platform_discount',
|
||||
'其中:红冲订单金额' => 'reversal_order_amount',
|
||||
'实发金额' => 'actual_shipped_amount',
|
||||
'实发数量' => 'actual_shipped_quantity',
|
||||
'实发成本' => 'actual_shipped_cost',
|
||||
'订单类型' => 'order_type',
|
||||
'登记时间' => 'registration_time',
|
||||
'确认时间' => 'confirmation_time',
|
||||
'进仓时间' => 'warehouse_entry_time',
|
||||
'线上商品名称' => 'online_product_name',
|
||||
'发货前申请数量' => 'pre_application_quantity',
|
||||
'发货前申请金额' => 'pre_application_amount',
|
||||
'发货前申请成本' => 'pre_application_cost',
|
||||
'发货前退回数量' => 'pre_return_quantity',
|
||||
'发货前退回金额' => 'pre_return_amount',
|
||||
'发货前退回成本' => 'pre_return_cost',
|
||||
'发货前退款金额' => 'pre_refund_amount',
|
||||
'发货前退款成本' => 'pre_refund_cost',
|
||||
'发货前退平台补贴' => 'pre_platform_subsidy',
|
||||
'发货后申请数量' => 'post_application_quantity',
|
||||
'发货后申请金额' => 'post_application_amount',
|
||||
'发货后申请成本' => 'post_application_cost',
|
||||
'发货后退回数量' => 'post_return_quantity',
|
||||
'发货后退回金额' => 'post_return_amount',
|
||||
'发货后退回成本' => 'post_return_cost',
|
||||
'发货后退款金额' => 'post_refund_amount',
|
||||
'发货后退款成本' => 'post_refund_cost',
|
||||
'发货后退平台补贴' => 'post_platform_subsidy',
|
||||
'其中:次品商品数量' => 'defective_quantity',
|
||||
'退款类型' => 'refund_type',
|
||||
'问题类型' => 'question_type',
|
||||
'明细退货类型' => 'detail_return_type',
|
||||
'分类单' => 'classification_order',
|
||||
'赠品' => 'is_gift',
|
||||
'组合装' => 'combine_type',
|
||||
'订单标签' => 'order_tags',
|
||||
'售后单标签' => 'after_sales_tags',
|
||||
'商品标签' => 'item_tags',
|
||||
'发货仓编号' => 'shipping_warehouse_code',
|
||||
'发货仓库' => 'shipping_warehouse',
|
||||
'运营云仓' => 'operation_cloud_warehouse',
|
||||
'收货仓编号' => 'receiving_warehouse_code',
|
||||
'收货仓' => 'receiving_warehouse',
|
||||
'供销商编号' => 'supplier_code',
|
||||
'供销商' => 'supplier_name',
|
||||
'快递单号' => 'express_no',
|
||||
'快递公司' => 'express_company',
|
||||
'省' => 'province',
|
||||
'市' => 'city',
|
||||
'业务员' => 'salesperson',
|
||||
'线下客户编号' => 'offline_customer_code',
|
||||
'线下客户编码' => 'offline_customer_code',
|
||||
'线下客户名称' => 'offline_customer_name',
|
||||
'500112冲减收入' => 'account_500112',
|
||||
'60031erp快递费' => 'account_60031',
|
||||
'800101其他服务费' => 'account_800101',
|
||||
'800103其他活动费用' => 'account_800103',
|
||||
'800104违约赔付' => 'account_800104',
|
||||
'800105平台佣金' => 'account_800105',
|
||||
'800105佣金' => 'account_800105',
|
||||
'800106保证金' => 'account_800106',
|
||||
'800107推广充值' => 'account_800107',
|
||||
'800108品牌新享' => 'account_800108',
|
||||
'800109淘宝客/多多进宝' => 'account_800109',
|
||||
'800110运费险' => 'account_800110',
|
||||
'800114公益宝贝' => 'account_800114',
|
||||
'8001201直通车/钻展/快车等' => 'account_8001201',
|
||||
'8001202推广红包冲减' => 'account_8001202',
|
||||
'800122售后补偿' => 'account_800122',
|
||||
'800125红包' => 'account_800125',
|
||||
'800127退货运费' => 'account_800127',
|
||||
'800129退款' => 'account_800129',
|
||||
'800130订单' => 'account_800130',
|
||||
'800132其他费用' => 'account_800132',
|
||||
'800133品牌新享-收入' => 'account_800133',
|
||||
'800304售后补偿-聚工单' => 'account_800304',
|
||||
'800501种菜佣金' => 'account_800501',
|
||||
'800503发空包/赠品费用' => 'account_800503',
|
||||
],
|
||||
|
||||
'number_keys' => [
|
||||
'product_quantity', 'product_amount', 'product_cost', 'reversal_order_cost',
|
||||
'manual_adjust_income', 'manual_adjust_cost', 'merchant_discount', 'freight_income',
|
||||
'payment_amount', 'platform_discount', 'reversal_order_amount', 'actual_shipped_amount',
|
||||
'actual_shipped_quantity', 'actual_shipped_cost', 'pre_application_quantity',
|
||||
'pre_application_amount', 'pre_application_cost', 'pre_return_quantity', 'pre_return_amount',
|
||||
'pre_return_cost', 'pre_refund_amount', 'pre_refund_cost', 'pre_platform_subsidy',
|
||||
'post_application_quantity', 'post_application_amount', 'post_application_cost',
|
||||
'post_return_quantity', 'post_return_amount', 'post_return_cost', 'post_refund_amount',
|
||||
'post_refund_cost', 'post_platform_subsidy', 'defective_quantity',
|
||||
'account_500112', 'account_60031', 'account_800101', 'account_800103', 'account_800104',
|
||||
'account_800105', 'account_800106', 'account_800107', 'account_800108', 'account_800109',
|
||||
'account_800110', 'account_800114', 'account_8001201', 'account_8001202', 'account_800122',
|
||||
'account_800125', 'account_800127', 'account_800129', 'account_800130', 'account_800132',
|
||||
'account_800133', 'account_800304', 'account_800501', 'account_800503',
|
||||
],
|
||||
];
|
||||
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),
|
||||
@ -211,8 +218,13 @@ return [
|
||||
'full_reconcile_hours' => 1,
|
||||
'columns' => ['Id', 'guid', 'sourcefrom', 'coopersgn', 'scmcode', 'scmname', 'scmtype',
|
||||
'tradebody', 'company', 'enterpriseType', 'creditcode', 'handlePerson', 'accountbankno', 'legalPerson',
|
||||
'buyer', 'tradebtime', 'tradeetime', 'cooperbtime', 'cooperetime', 'memo',
|
||||
'updator', 'local_created', 'local_updated'],
|
||||
'paycyclesgn', 'corpmobile', 'tradebtime', 'tradeetime', 'cooperbtime', 'cooperetime',
|
||||
'undefined1', 'undefined2', 'dsper', 'reconciliter', 'levelname',
|
||||
'linkerprovience', 'linkercity', 'linkerarea', 'linkeradd', 'memo',
|
||||
'buyer', 'buymid', 'buytop', 'craftType', 'outline', 'capacity', 'freereason',
|
||||
'area', 'stocker', 'region', 'sindou', 'follower', 'presc', 'strategy',
|
||||
'updator', 'local_created', 'local_updated', 'send_date',
|
||||
'endTimeA', 'endTimeB', 'finishTimeA', 'finishTimeB'],
|
||||
'indexes' => [['scmcode', 'Id'], ['coopersgn', 'Id']],
|
||||
],
|
||||
'jiansun.dbo.js_supplier_contract_View' => [
|
||||
@ -313,9 +325,14 @@ return [
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'updateTime',
|
||||
'indexes' => [
|
||||
['Id'],
|
||||
['as_date', 'Id'],
|
||||
['o_id', 'Id'],
|
||||
['as_id', 'Id'],
|
||||
['shop_id', 'outer_as_id(100)'],
|
||||
['so_id(64)'],
|
||||
['outer_as_id(64)'],
|
||||
['as_date', 'Id', 'as_id', 'shop_site(300)'],
|
||||
],
|
||||
],
|
||||
'operations_replica.rpt.jst_returnAndRefundItem' => [
|
||||
@ -324,11 +341,14 @@ return [
|
||||
'object' => 'jst_returnAndRefundItem',
|
||||
'target' => 'ops_jst_return_refund_item',
|
||||
'keys' => ['Id'],
|
||||
// The replica can replace historical detail IDs. Append-only sync
|
||||
// cannot discover those changes; reconcile in bounded weekly runs.
|
||||
'full_reconcile_hours' => 168,
|
||||
'indexes' => [
|
||||
['as_id', 'Id'],
|
||||
],
|
||||
],
|
||||
'operations_replica.rpt.jst_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
|
||||
'operations_replica.rpt.jst_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime', 'indexes' => [['shop_id', 'Id']]],
|
||||
|
||||
// 淘系(天猫/淘宝)订单与售后:聚水潭单独存放在 qmcust 这套表里,
|
||||
// 与 jst_Orders / jst_OrderItem / jst_returnAndRefundInfo 分开。
|
||||
@ -371,6 +391,8 @@ return [
|
||||
['as_id'],
|
||||
['o_id', 'as_id'],
|
||||
['as_date', 'as_id'],
|
||||
['so_id(64)'],
|
||||
['outer_as_id(64)'],
|
||||
],
|
||||
],
|
||||
'operations_replica.rpt.jst_qmcust_refundlist_item' => [
|
||||
@ -384,6 +406,34 @@ return [
|
||||
['as_id', 'asi_id'],
|
||||
],
|
||||
],
|
||||
|
||||
// 聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」的原表。
|
||||
// 列名是 undefined1..undefinedN,中文表头由 ..._schemas 表提供映射。
|
||||
'operations_replica.rpt.jst_goods_profit_kpi_items_afs' => [
|
||||
'source_key' => 'operations_replica',
|
||||
'schema' => 'rpt',
|
||||
'object' => 'jst_goods_profit_kpi_items_afs',
|
||||
'target' => 'ops_jst_goods_profit_kpi_items_afs',
|
||||
'keys' => ['id'],
|
||||
'watermark' => 'updatedAt',
|
||||
'batch_size' => 2000,
|
||||
'max_batches_per_run' => 4,
|
||||
// 160 多个 nvarchar 列,整行按 VARCHAR 建表会超过 InnoDB 行长度上限。
|
||||
'wide_strings' => true,
|
||||
'indexes' => [
|
||||
['undefined8', 'undefined7'],
|
||||
['undefined2'],
|
||||
['undefined10'],
|
||||
],
|
||||
],
|
||||
'operations_replica.rpt.jst_goods_profit_kpi_items_afs_schemas' => [
|
||||
'source_key' => 'operations_replica',
|
||||
'schema' => 'rpt',
|
||||
'object' => 'jst_goods_profit_kpi_items_afs_schemas',
|
||||
'target' => 'ops_jst_goods_profit_kpi_items_afs_schemas',
|
||||
'keys' => ['id'],
|
||||
'watermark' => 'id',
|
||||
],
|
||||
'operations_replica.rpt.jst_originalBillInfo' => [
|
||||
'source_key' => 'operations_replica',
|
||||
'schema' => 'rpt',
|
||||
|
||||
@ -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,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('business_courier_stock_quote_entries', function (Blueprint $table): void {
|
||||
$table->date('effective_date')->nullable();
|
||||
$table->date('end_date')->nullable();
|
||||
});
|
||||
DB::table('business_courier_stock_quote_entries')->orderBy('id')->chunkById(200, function ($entries): void {
|
||||
foreach ($entries as $entry) {
|
||||
$change = DB::table('business_courier_quote_changes')->where('id', $entry->source_change_id)->first();
|
||||
if ($change) DB::table('business_courier_stock_quote_entries')->where('id', $entry->id)->update([
|
||||
'effective_date' => $change->effective_date, 'end_date' => $change->end_date,
|
||||
]);
|
||||
}
|
||||
});
|
||||
Schema::table('business_courier_stock_quote_entries', function (Blueprint $table): void {
|
||||
$table->dropUnique('courier_stock_quote_identity_unique');
|
||||
$table->dropUnique('courier_stock_quote_name_unique');
|
||||
$table->index(['company_id', 'courier_expcode', 'quote_type', 'effective_date'], 'stock_quote_code_period');
|
||||
$table->index(['company_id', 'courier_name_key', 'quote_type', 'effective_date'], 'stock_quote_name_period');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// 多时期记录不能自动缩回单期唯一约束,避免回滚时丢失历史报价。
|
||||
throw new RuntimeException('多时期报价迁移需核对已录入版本后手动回退;应用可直接回滚镜像。');
|
||||
}
|
||||
};
|
||||
@ -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,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/** 供应商应付「厂家记账」按工单类型 + 发起时间读取聚工单镜像。 */
|
||||
return new class extends Migration
|
||||
{
|
||||
private const TABLE = 'js_jst_work_order_info';
|
||||
|
||||
private const INDEX = 'idx_supplier_bookkeeping_created';
|
||||
|
||||
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(self::TABLE) || $schema->hasIndex(self::TABLE, self::INDEX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table(self::TABLE, static fn (Blueprint $table) => $table->index(['woTypeId', 'created', 'Id'], self::INDEX));
|
||||
}
|
||||
|
||||
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(self::TABLE) || ! $schema->hasIndex(self::TABLE, self::INDEX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema->table(self::TABLE, static fn (Blueprint $table) => $table->dropIndex(self::INDEX));
|
||||
}
|
||||
};
|
||||
@ -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,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 财务台账账户状态。
|
||||
*
|
||||
* 状态由财务人员在页面维护,独立于可被全量覆盖的 fin_accounts 基础表,
|
||||
* 因此重新导入《银行当前余额》不会丢失。
|
||||
*/
|
||||
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_account_statuses')) {
|
||||
$schema->create('fin_account_statuses', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('account_short', 100)->unique('fin_account_statuses_account_unique');
|
||||
$table->string('status', 40)->default('normal');
|
||||
$table->string('updated_by', 100)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status'], 'fin_account_statuses_status_index');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::connection($this->connection())->dropIfExists('fin_account_statuses');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 财务台账账户类型人工维护值。
|
||||
*
|
||||
* 基础表 fin_accounts 在导入时会被全量覆盖,因此人工调整后的类型单独保存。
|
||||
*/
|
||||
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_account_type_overrides')) {
|
||||
$schema->create('fin_account_type_overrides', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('account_short', 100)->unique('fin_account_type_overrides_account_unique');
|
||||
$table->string('account_type', 50)->nullable();
|
||||
$table->string('updated_by', 100)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['account_type'], 'fin_account_type_overrides_type_index');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::connection($this->connection())->dropIfExists('fin_account_type_overrides');
|
||||
}
|
||||
};
|
||||
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