Compare commits
312 Commits
feature/li
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a695e49dd | |||
| cf9bcf8b72 | |||
| d5e607f356 | |||
| 85b82bfe47 | |||
| f28ea39677 | |||
| 4c30f2e628 | |||
| 3c7757d848 | |||
| e75946ae88 | |||
| 86306f9901 | |||
| 9db0092c53 | |||
| 630630b144 | |||
| 9f214868ab | |||
| 4401b0450a | |||
| 1dc006ad29 | |||
| 44b2bf2433 | |||
| 03acb8b1a0 | |||
| c4f06427fe | |||
| d488892693 | |||
| c78d684d45 | |||
| 11d6b2899c | |||
| 6fd059ae98 | |||
| 32724712ba | |||
| 0b69f30714 | |||
| 4cf90cd3a6 | |||
| fcd54a8080 | |||
| ff70c61007 | |||
| 3cff23c595 | |||
| 716ac44182 | |||
| 2edb87c6d0 | |||
| 27031e9067 | |||
| c9f5e358c4 | |||
| 98436bfd0a | |||
| 06707791e8 | |||
| 7170b4626a | |||
| dd8dfddacf | |||
| 50ab5974fb | |||
| 4386db5647 | |||
| bcbb5d5a50 | |||
| 13c9d031c1 | |||
| 75e1db5270 | |||
| 9ba0508412 | |||
| cf5c7b80f3 | |||
| 65868b7065 | |||
| 6ba00cdf42 | |||
| b5a1d55477 | |||
| 7be91948ad | |||
| 46b9acae2e | |||
| 0f8ab6e5ae | |||
| 4ab072b37a | |||
| 8c5ec8b34f | |||
| d22cc9e279 | |||
| 091494113a | |||
| 14e6f6465e | |||
| 3d0300fbab | |||
| 1d9b57898f | |||
| 89bd1703c7 | |||
| ea8a94bccd | |||
| 9c4a86aa24 | |||
| e87400880b | |||
| 5d8bbc36a2 | |||
| 4a343b92a1 | |||
| 9577099087 | |||
| 01104d722d | |||
| abf4b92fe0 | |||
| 412f4129cc | |||
| 0c92ae62b1 | |||
| c0287e0e26 | |||
| 252f2bed83 | |||
| 41db35f1e9 | |||
| 37090291b1 | |||
| 43e278f1b6 | |||
| 054b239867 | |||
| 4e5896d8c3 | |||
| eeaa386b04 | |||
| 9a9e01461c | |||
| c78ce5e00c | |||
| 3066d2c7d8 | |||
| b4ce8f32a9 | |||
| 4468b7f28c | |||
| ab308fe388 | |||
| f340a295d5 | |||
| 7ce3ea340c | |||
| 4f58ad967b | |||
| b1c3bcd851 | |||
| d2b1f9e736 | |||
| 51db06d919 | |||
| 955d706e56 | |||
| 4ffd853e7a | |||
| 85e7fc096d | |||
| e15b6b9901 | |||
| 127287a289 | |||
| f7197b4976 | |||
| 53bd1dc44a | |||
| f1ecd4a978 | |||
| e3bcffd0f7 | |||
| 1ad248dda6 | |||
| 2fa66ef1d1 | |||
| 1991b7d319 | |||
| c9ecf033d5 | |||
| f61fbb5028 | |||
| 6908546d44 | |||
| 238bff99e9 | |||
| 3bdc3382db | |||
| 1d33cc2654 | |||
| ee9205dca7 | |||
| acab9b2905 | |||
| cfb56c98a7 | |||
| 0893f3d8d3 | |||
| 9711ec4f57 | |||
| 812b633146 | |||
| 562973438d | |||
| 3fe7833f87 | |||
| 4206f2f9ba | |||
| 58e0c990e2 | |||
| fa469fafec | |||
| 2c1bea34e0 | |||
| 2852c1332f | |||
| 00511c4577 | |||
| 260014a4a0 | |||
| 920fbcb650 | |||
| 446da08445 | |||
| 7280d86de9 | |||
| 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 | |||
| 8aa7676853 | |||
| 3b4db0e273 | |||
| 2ffb49ca92 | |||
| 2a80f380e6 | |||
| 87eaf48c2c | |||
| 82c79dadc3 | |||
| bcf8ea8fb7 | |||
| 656c655c91 | |||
| 7742163632 | |||
| 8429ad0642 | |||
| 0a54775b3b | |||
| b26ab9f85d | |||
| 688ae30747 | |||
| b4eaa1e102 | |||
| e6f4b61761 | |||
| 3e1d211d1b | |||
| 7fbcd061af | |||
| da2a26c485 | |||
| 37c11f4e8e | |||
| 2399d42d6d | |||
|
|
5b89692318 | ||
| c4a39ec265 | |||
| c50244544d | |||
| f3df42f46b | |||
| 706b07b4ad | |||
| 9a25db91cc | |||
| 6c76a373a3 | |||
| 8fbc9abd53 | |||
| b0ea247ca1 | |||
| e064492a95 | |||
| 422497cfcc | |||
| 74a9b5b81f | |||
| 23b8087d35 | |||
| d1b14fc10d | |||
| 5c20defc79 | |||
| 01abeb4124 | |||
| 173ca7c2ec | |||
| 564eb1ef08 | |||
| 1706131d9c | |||
| f4a0014690 | |||
| 288184c8dc | |||
| 60163f6c32 | |||
| c30cff7d0d | |||
| 6518995e45 | |||
| 3e222e0035 | |||
| f41a8fa6ba | |||
| 1214fc365e | |||
| a4952dfa42 | |||
| 0d215fe20c | |||
| cccd321195 | |||
| 1131d073ce | |||
| 72b3f43da5 | |||
| c314209705 | |||
| 4e38a1cf11 | |||
| fd4b155979 | |||
| 8d7f6da49f |
@ -10,6 +10,7 @@ runtime
|
||||
*.md
|
||||
JetBrainsMono-字体
|
||||
node_modules
|
||||
**/__pycache__
|
||||
vendor
|
||||
bootstrap/cache/*
|
||||
.caiwu-build
|
||||
@ -22,6 +23,7 @@ storage/debugbar
|
||||
storage/framework/cache/data/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
storage/framework/testing
|
||||
storage/logs/*
|
||||
tests
|
||||
test-data
|
||||
|
||||
8
.github/workflows/tests.yml
vendored
8
.github/workflows/tests.yml
vendored
@ -38,8 +38,14 @@ jobs:
|
||||
- name: Copy .env
|
||||
run: cp .env.testing .env
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install NPM
|
||||
run: npm install
|
||||
run: npm ci --legacy-peer-deps --no-audit --no-fund
|
||||
|
||||
- name: Compile assets
|
||||
run: npm run production
|
||||
|
||||
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。
|
||||
|
||||
|
||||
163
app/Business/CourierContractPageData.php
Normal file
163
app/Business/CourierContractPageData.php
Normal file
@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
/** Read the signed snapshot directly: no duplicate contracts, attachment copies or archive backfill. */
|
||||
class CourierContractPageData
|
||||
{
|
||||
public const REMINDER_DAYS = 30;
|
||||
|
||||
public const STATUSES = [
|
||||
'due' => '即将到期',
|
||||
'expired' => '约定期限已届满',
|
||||
'active' => '有效期内',
|
||||
'upcoming' => '未到开始日期',
|
||||
'unknown' => '期限待核对',
|
||||
];
|
||||
|
||||
public function page(bool $includeRows = true): array
|
||||
{
|
||||
$search = mb_substr(trim((string) request()->query('contract_search', '')), 0, 255);
|
||||
$status = (string) request()->query('contract_status', '');
|
||||
$status = array_key_exists($status, self::STATUSES) ? $status : '';
|
||||
$from = $this->date(request()->query('contract_end_from'));
|
||||
$to = $this->date(request()->query('contract_end_to'));
|
||||
if ($from && $to && $from > $to) {
|
||||
[$from, $to] = [$to, $from];
|
||||
}
|
||||
$filters = ['contract_search' => $search, 'contract_status' => $status,
|
||||
'contract_end_from' => $from ?? '', 'contract_end_to' => $to ?? ''];
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
$page = max((int) request()->query('page', 1), 1);
|
||||
$options = ['path' => request()->url(), 'query' => request()->except('page')];
|
||||
$data = [
|
||||
'courierContractFilters' => $filters,
|
||||
'courierContractRows' => new LengthAwarePaginator([], 0, $limit, $page, $options),
|
||||
'courierContractCounts' => array_fill_keys(array_keys(self::STATUSES), 0),
|
||||
'courierContractUnreadable' => 0,
|
||||
'courierContractError' => null,
|
||||
];
|
||||
|
||||
try {
|
||||
if (! Schema::hasTable('business_courier_agreement_signatures')) {
|
||||
$data['courierContractError'] = '协议签署数据表尚未建立,合同管理暂时无法读取。';
|
||||
return $data;
|
||||
}
|
||||
|
||||
$matched = [];
|
||||
$today = Carbon::today(config('app.timezone'));
|
||||
// Dates and partner types are encrypted. Stream bounded chunks instead of SQL JSON queries
|
||||
// or persisting a second, potentially stale copy of the signed business snapshot.
|
||||
$signatures = CourierAgreementSignature::query()->where('company_id', (int) company_id())
|
||||
->where('document_type', 'cooperation-contract')->where('status', CourierAgreementSignature::STATUS_ARCHIVED)
|
||||
->select(['id', 'company_id', 'business_no', 'courier_name', 'payload', 'document_type', 'status',
|
||||
'signed_pdf_path', 'certificate_path', 'archived_at', 'created_by', 'created_at'])
|
||||
->lazyById(250);
|
||||
foreach ($signatures as $signature) {
|
||||
$payload = $signature->archivedCourierContractPayload();
|
||||
if ($payload === null) {
|
||||
try {
|
||||
$signature->payload;
|
||||
} catch (Throwable) {
|
||||
$data['courierContractUnreadable']++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$row = $this->snapshot($signature, $payload, $today);
|
||||
$data['courierContractCounts'][$row['status']]++;
|
||||
if (! $includeRows || ($status !== '' && $status !== $row['status'])) {
|
||||
continue;
|
||||
}
|
||||
if (($from && (! $row['end_date'] || $row['end_date'] < $from))
|
||||
|| ($to && (! $row['end_date'] || $row['end_date'] > $to))) {
|
||||
continue;
|
||||
}
|
||||
if ($search !== '' && ! str_contains(mb_strtolower(implode(' ', [
|
||||
$row['courier_name'], $row['number'], $row['business_no'], $row['party_a'], $row['party_b'],
|
||||
])), mb_strtolower($search))) {
|
||||
continue;
|
||||
}
|
||||
$matched[] = $row;
|
||||
}
|
||||
if ($includeRows) {
|
||||
$priority = ['due' => 0, 'expired' => 1, 'unknown' => 2, 'active' => 3, 'upcoming' => 4];
|
||||
usort($matched, static fn (array $a, array $b): int =>
|
||||
[$priority[$a['status']], $a['end_date'] ?? '9999-12-31', -$a['id']]
|
||||
<=> [$priority[$b['status']], $b['end_date'] ?? '9999-12-31', -$b['id']]);
|
||||
$total = count($matched);
|
||||
$items = array_slice($matched, ($page - 1) * $limit, $limit);
|
||||
$owners = user_model_class()::query()->whereIn('id', array_filter(array_column($items, 'created_by')))
|
||||
->pluck('name', 'id');
|
||||
foreach ($items as &$row) {
|
||||
$row['creator'] = $owners[$row['created_by']] ?? '未记录';
|
||||
$row['has_signed_file'] = $this->listedFileAvailable($row['signed_path']);
|
||||
$row['has_certificate'] = $this->listedFileAvailable($row['certificate_path']);
|
||||
unset($row['signed_path'], $row['certificate_path']);
|
||||
}
|
||||
unset($row);
|
||||
$data['courierContractRows'] = new LengthAwarePaginator($items, $total, $limit, $page, $options);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$data['courierContractCounts'] = array_fill_keys(array_keys(self::STATUSES), 0);
|
||||
$data['courierContractError'] = '归档合同暂时无法读取,请稍后重试。';
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function snapshot(CourierAgreementSignature $signature, array $payload, Carbon $today): array
|
||||
{
|
||||
$start = $this->date($payload['cooperation_start_date'] ?? null);
|
||||
$end = $this->date($payload['cooperation_end_date'] ?? null);
|
||||
$days = $end ? (int) $today->diffInDays(Carbon::parse($end, config('app.timezone')), false) : null;
|
||||
$status = match (true) {
|
||||
! $start || ! $end || $start > $end => 'unknown',
|
||||
$end < $today->toDateString() => 'expired',
|
||||
$start > $today->toDateString() => 'upcoming',
|
||||
$days <= self::REMINDER_DAYS => 'due',
|
||||
default => 'active',
|
||||
};
|
||||
$person = ($payload['counterparty_signer_type'] ?? 'organization') === 'person';
|
||||
|
||||
return [
|
||||
'id' => (int) $signature->id,
|
||||
'courier_name' => (string) $signature->courier_name,
|
||||
'business_no' => (string) $signature->business_no,
|
||||
'number' => trim((string) ($payload['cooperation_contract_number'] ?? '')),
|
||||
'party_a' => trim((string) ($payload['cooperation_party_a_name'] ?? '')),
|
||||
'party_b' => trim((string) ($payload['cooperation_party_b_name'] ?? '')),
|
||||
'legal_representative' => $person ? '不适用(个人签署)' : trim((string) ($payload['cooperation_party_b_legal_representative'] ?? '')),
|
||||
'start_date' => $start, 'end_date' => $end, 'days' => $status === 'unknown' ? null : $days,
|
||||
'status' => $status, 'created_by' => $signature->created_by,
|
||||
'created_at' => $signature->created_at, 'archived_at' => $signature->archived_at,
|
||||
'signed_path' => $signature->signed_pdf_path, 'certificate_path' => $signature->certificate_path,
|
||||
];
|
||||
}
|
||||
|
||||
private function date(mixed $value): ?string
|
||||
{
|
||||
if (! is_string($value) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
|
||||
return null;
|
||||
}
|
||||
[$year, $month, $day] = array_map('intval', explode('-', $value));
|
||||
return checkdate($month, $day, $year) ? $value : null;
|
||||
}
|
||||
|
||||
private function listedFileAvailable(?string $path): bool
|
||||
{
|
||||
if (blank($path)) {
|
||||
return false;
|
||||
}
|
||||
// Do not issue up to 50 sequential object-storage HEAD requests for one list page.
|
||||
// Downloads always verify the actual file; local files can be checked cheaply here.
|
||||
return config('filesystems.disks.uploads.driver') !== 'local' || Storage::disk('uploads')->exists($path);
|
||||
}
|
||||
}
|
||||
237
app/Business/InventoryInboundDetailImporter.php
Normal file
237
app/Business/InventoryInboundDetailImporter.php
Normal file
@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use DateTimeImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use RuntimeException;
|
||||
|
||||
class InventoryInboundDetailImporter
|
||||
{
|
||||
private const TABLE = 'inventory_inbound_detail_rows';
|
||||
|
||||
/**
|
||||
* Replace the imported inbound detail snapshot only after the workbook has
|
||||
* been opened successfully. The delete and inserts share one transaction,
|
||||
* so a malformed file cannot leave the page with a partially imported table.
|
||||
*
|
||||
* @return array{imported:int,mapped:int,skipped:int,total:int}
|
||||
*/
|
||||
public function import(string $file, string $forcedMonth = ''): array
|
||||
{
|
||||
$path = realpath($file);
|
||||
if ($path === false || ! is_file($path) || ! is_readable($path)) {
|
||||
throw new RuntimeException('入库明细文件不存在或不可读取。');
|
||||
}
|
||||
|
||||
$forcedMonth = $this->normalizeForcedMonth($forcedMonth);
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$now = now();
|
||||
$maps = $this->shortNameMap();
|
||||
|
||||
$reader = IOFactory::createReaderForFile($path);
|
||||
$reader->setReadDataOnly(true);
|
||||
$spreadsheet = $reader->load($path);
|
||||
$sheet = $spreadsheet->getSheet(0);
|
||||
|
||||
try {
|
||||
return DB::connection($connection)->transaction(function () use (
|
||||
$connection,
|
||||
$sheet,
|
||||
$maps,
|
||||
$forcedMonth,
|
||||
$now,
|
||||
): array {
|
||||
DB::connection($connection)->table(self::TABLE)->delete();
|
||||
|
||||
$batch = [];
|
||||
$imported = 0;
|
||||
$mapped = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($sheet->getRowIterator(2) as $row) {
|
||||
$rowNumber = $row->getRowIndex();
|
||||
$cells = [];
|
||||
foreach ($row->getCellIterator('A', 'U') as $cell) {
|
||||
$cells[] = $cell->getFormattedValue();
|
||||
}
|
||||
|
||||
$warehouse = trim((string) ($cells[14] ?? ''));
|
||||
$createdRaw = trim((string) ($cells[16] ?? ''));
|
||||
if ($warehouse === '' && $createdRaw === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$month = $forcedMonth !== '' ? $forcedMonth : $this->monthOf($createdRaw);
|
||||
if ($month === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 代发仓按仓储方匹配;其它仓库按仓库名称做唯一匹配。
|
||||
$shortName = '';
|
||||
if ($warehouse !== '') {
|
||||
$shortName = str_contains($warehouse, '代发仓')
|
||||
? ($maps['provider'][$warehouse] ?? $maps['warehouse'][$warehouse] ?? '')
|
||||
: ($maps['warehouse'][$warehouse] ?? '');
|
||||
}
|
||||
if ($shortName !== '') {
|
||||
$mapped++;
|
||||
}
|
||||
|
||||
$batch[] = [
|
||||
'report_month' => $month,
|
||||
'row_key' => sha1(substr($month, 0, 7) . '|' . $rowNumber),
|
||||
'source_no' => $this->text($cells[0] ?? ''),
|
||||
'document_no' => $this->text($cells[1] ?? ''),
|
||||
'created_serial' => $this->text($cells[2] ?? ''),
|
||||
'supplier' => $this->text($cells[3] ?? ''),
|
||||
'item_no' => $this->text($cells[4] ?? ''),
|
||||
'barcode' => $this->text($cells[5] ?? ''),
|
||||
'spec_name' => $this->text($cells[6] ?? ''),
|
||||
'quantity' => $this->number($cells[7] ?? ''),
|
||||
'unit_price' => $this->number($cells[8] ?? ''),
|
||||
'amount' => $this->number($cells[9] ?? ''),
|
||||
'estimated_unit_price' => $this->number($cells[10] ?? ''),
|
||||
'estimated_amount' => $this->number($cells[11] ?? ''),
|
||||
'supplier_barcode' => $this->text($cells[12] ?? ''),
|
||||
'remark' => $this->text($cells[13] ?? ''),
|
||||
'warehouse' => $warehouse !== '' ? $warehouse : null,
|
||||
'warehouse_short_name' => $shortName !== '' ? $shortName : null,
|
||||
'created_at_raw' => $this->moment($createdRaw),
|
||||
'express_name' => $this->text($cells[17] ?? ''),
|
||||
'express_no' => $this->text($cells[18] ?? ''),
|
||||
'not_sent' => $this->text($cells[19] ?? ''),
|
||||
'owner_name' => $this->text($cells[20] ?? ''),
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$imported++;
|
||||
|
||||
if (count($batch) >= 1000) {
|
||||
DB::connection($connection)->table(self::TABLE)->insertOrIgnore($batch);
|
||||
$batch = [];
|
||||
}
|
||||
}
|
||||
|
||||
if ($batch !== []) {
|
||||
DB::connection($connection)->table(self::TABLE)->insertOrIgnore($batch);
|
||||
}
|
||||
if ($imported === 0) {
|
||||
throw new RuntimeException('入库明细文件中没有可导入的数据。');
|
||||
}
|
||||
|
||||
return [
|
||||
'imported' => $imported,
|
||||
'mapped' => $mapped,
|
||||
'skipped' => $skipped,
|
||||
'total' => DB::connection($connection)->table(self::TABLE)->count(),
|
||||
];
|
||||
});
|
||||
} finally {
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeForcedMonth(string $month): string
|
||||
{
|
||||
$month = trim($month);
|
||||
if ($month === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
|
||||
return $month . '-01';
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $month) === 1) {
|
||||
return $month;
|
||||
}
|
||||
|
||||
throw new RuntimeException('指定月份格式应为 YYYY-MM。');
|
||||
}
|
||||
|
||||
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
|
||||
private function shortNameMap(): array
|
||||
{
|
||||
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$warehouse = trim((string) $row->warehouse);
|
||||
if ($warehouse === '') {
|
||||
continue;
|
||||
}
|
||||
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
|
||||
$byWarehouse = [];
|
||||
foreach ($grouped as $warehouse => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byWarehouse[$warehouse] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
$byProvider = [];
|
||||
foreach ($rows as $row) {
|
||||
$provider = trim((string) $row->warehouse_provider);
|
||||
if ($provider !== '') {
|
||||
$byProvider[$provider][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
}
|
||||
foreach ($byProvider as $provider => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byProvider[$provider] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
|
||||
}
|
||||
|
||||
private function monthOf(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
|
||||
$parsed = DateTimeImmutable::createFromFormat($format, $value);
|
||||
if ($parsed instanceof DateTimeImmutable) {
|
||||
return $parsed->format('Y-m') . '-01';
|
||||
}
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? '' : date('Y-m-01', $timestamp);
|
||||
}
|
||||
|
||||
private function moment(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
|
||||
private function text(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function number(mixed $value): ?float
|
||||
{
|
||||
$text = str_replace(',', '', trim((string) $value));
|
||||
if ($text === '' || ! is_numeric($text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
261
app/Business/ProductMonthlyWeightedAverageReporter.php
Normal file
261
app/Business/ProductMonthlyWeightedAverageReporter.php
Normal file
@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 汇总商品月加权平均单价结果,并将入库明细差异按原因分类。
|
||||
*/
|
||||
class ProductMonthlyWeightedAverageReporter
|
||||
{
|
||||
// 异常阈值未由业务另行指定:取至少 100 行,且不超过总行数的 0.1%。
|
||||
private const ANOMALY_THRESHOLD_MIN_ROWS = 100;
|
||||
|
||||
private const ANOMALY_THRESHOLD_RATE = 0.001;
|
||||
|
||||
private const ROW_TABLE = 'product_monthly_weighted_average_rows';
|
||||
|
||||
private const STATE_TABLE = 'product_monthly_weighted_average_states';
|
||||
|
||||
private const INBOUND_TABLE = 'inventory_inbound_detail_rows';
|
||||
|
||||
private const INVENTORY_MONTH_TABLE = 'inventory_report_authoritative_months';
|
||||
|
||||
private const INVENTORY_ROW_TABLE = 'inventory_report_authoritative_rows';
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function build(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$month = $month->startOfMonth();
|
||||
$db = DB::connection($connection);
|
||||
$baseQuery = static fn () => $db->table(self::ROW_TABLE)
|
||||
->where('report_month', $month->toDateString());
|
||||
|
||||
$totals = $baseQuery()
|
||||
->selectRaw('count(*) as total_rows')
|
||||
->selectRaw('coalesce(sum(opening_quantity), 0) as opening_quantity')
|
||||
->selectRaw('coalesce(sum(opening_amount), 0) as opening_amount')
|
||||
->selectRaw('coalesce(sum(purchase_quantity), 0) as purchase_quantity')
|
||||
->selectRaw('coalesce(sum(purchase_amount), 0) as purchase_amount')
|
||||
->selectRaw('sum(case when weighted_average_unit_price is not null then 1 else 0 end) as weighted_price_rows')
|
||||
->selectRaw('sum(case when weighted_average_unit_price is null then 1 else 0 end) as missing_weighted_rows')
|
||||
->selectRaw('sum(case when pricing_status = ? then 1 else 0 end) as manual_review_rows', ['待人工核查'])
|
||||
->first();
|
||||
|
||||
if ($totals === null || (int) $totals->total_rows === 0) {
|
||||
return [
|
||||
'status' => 'missing',
|
||||
'report_month' => $month->toDateString(),
|
||||
'message' => '该月份尚未生成商品月加权平均单价。',
|
||||
'generated_at' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
$weightedKeys = [];
|
||||
foreach ($baseQuery()->select('product_code', 'warehouse_short_name')->cursor() as $row) {
|
||||
$weightedKeys[$this->key($row->product_code, $row->warehouse_short_name)] = true;
|
||||
}
|
||||
|
||||
$inbound = [
|
||||
'source_rows' => 0,
|
||||
'source_quantity' => 0.0,
|
||||
'source_amount' => 0.0,
|
||||
'matched_rows' => 0,
|
||||
'matched_quantity' => 0.0,
|
||||
'matched_amount' => 0.0,
|
||||
'unmatched_rows' => 0,
|
||||
'unmatched_quantity' => 0.0,
|
||||
'unmatched_amount' => 0.0,
|
||||
'unmatched_groups' => [],
|
||||
];
|
||||
foreach ($db->table(self::INBOUND_TABLE)
|
||||
->where('report_month', $month->toDateString())
|
||||
->selectRaw('upper(barcode) as code, warehouse_short_name, count(*) as rows_count')
|
||||
->selectRaw('coalesce(sum(quantity), 0) as quantity')
|
||||
->selectRaw('coalesce(sum(coalesce(amount, 0) + coalesce(estimated_amount, 0)), 0) as amount')
|
||||
->groupByRaw('upper(barcode), warehouse_short_name')
|
||||
->cursor() as $row) {
|
||||
$rows = (int) $row->rows_count;
|
||||
$quantity = (float) $row->quantity;
|
||||
$amount = (float) $row->amount;
|
||||
$inbound['source_rows'] += $rows;
|
||||
$inbound['source_quantity'] += $quantity;
|
||||
$inbound['source_amount'] += $amount;
|
||||
|
||||
if (isset($weightedKeys[$this->key($row->code, $row->warehouse_short_name)])) {
|
||||
$inbound['matched_rows'] += $rows;
|
||||
$inbound['matched_quantity'] += $quantity;
|
||||
$inbound['matched_amount'] += $amount;
|
||||
continue;
|
||||
}
|
||||
|
||||
$inbound['unmatched_rows'] += $rows;
|
||||
$inbound['unmatched_quantity'] += $quantity;
|
||||
$inbound['unmatched_amount'] += $amount;
|
||||
$inbound['unmatched_groups'][] = [
|
||||
'product_code' => trim((string) $row->code),
|
||||
'warehouse_short_name' => trim((string) $row->warehouse_short_name),
|
||||
'source_rows' => $rows,
|
||||
'quantity' => $this->number($quantity),
|
||||
'amount' => $this->number($amount),
|
||||
'reason' => '入库明细未匹配到商品编码 + 仓库简称行',
|
||||
];
|
||||
}
|
||||
usort(
|
||||
$inbound['unmatched_groups'],
|
||||
static fn (array $left, array $right): int =>
|
||||
abs((float) $right['amount']) <=> abs((float) $left['amount']),
|
||||
);
|
||||
$inbound = $this->roundNestedNumbers($inbound);
|
||||
|
||||
$monthState = $db->table(self::INVENTORY_MONTH_TABLE)
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('source_month', $month->toDateString())
|
||||
->first();
|
||||
$generation = $monthState?->active_generation;
|
||||
$actualSourceRows = $generation === null
|
||||
? 0
|
||||
: $db->table(self::INVENTORY_ROW_TABLE)
|
||||
->where('generation', (string) $generation)
|
||||
->count();
|
||||
$expectedSourceRows = (int) ($monthState?->source_row_count ?? 0);
|
||||
|
||||
$weightedAnomalyRows = $baseQuery()
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('weighted_average_unit_price')
|
||||
->orWhere('pricing_status', '待人工核查');
|
||||
})
|
||||
->count();
|
||||
$reasons = [];
|
||||
if ((int) $totals->missing_weighted_rows > 0) {
|
||||
$reasons[] = [
|
||||
'code' => 'missing_weighted_price',
|
||||
'label' => '加权平均单价为空',
|
||||
'rows' => (int) $totals->missing_weighted_rows,
|
||||
];
|
||||
}
|
||||
if ((int) $totals->manual_review_rows > 0) {
|
||||
$reasons[] = [
|
||||
'code' => 'manual_review',
|
||||
'label' => '待人工核查',
|
||||
'rows' => (int) $totals->manual_review_rows,
|
||||
];
|
||||
}
|
||||
if ((int) $inbound['unmatched_rows'] > 0) {
|
||||
$reasons[] = [
|
||||
'code' => 'inbound_without_inventory_dimension',
|
||||
'label' => '入库明细未匹配到商品编码 + 仓库简称行',
|
||||
'rows' => (int) $inbound['unmatched_rows'],
|
||||
'groups' => count($inbound['unmatched_groups']),
|
||||
];
|
||||
}
|
||||
if ($actualSourceRows !== $expectedSourceRows) {
|
||||
$reasons[] = [
|
||||
'code' => 'upstream_source_row_mismatch',
|
||||
'label' => '上游进销存源表行数与已激活明细不一致',
|
||||
'rows' => abs($actualSourceRows - $expectedSourceRows),
|
||||
];
|
||||
}
|
||||
|
||||
$anomalyRows = $weightedAnomalyRows + (int) $inbound['unmatched_rows'];
|
||||
$threshold = max(
|
||||
self::ANOMALY_THRESHOLD_MIN_ROWS,
|
||||
(int) ceil((int) $totals->total_rows * self::ANOMALY_THRESHOLD_RATE),
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => $reasons === [] ? 'computed' : 'review_required',
|
||||
'report_month' => $month->toDateString(),
|
||||
'totals' => [
|
||||
'rows' => (int) $totals->total_rows,
|
||||
'opening_quantity' => $this->number((float) $totals->opening_quantity),
|
||||
'opening_amount' => $this->number((float) $totals->opening_amount),
|
||||
'purchase_quantity' => $this->number((float) $totals->purchase_quantity),
|
||||
'purchase_amount' => $this->number((float) $totals->purchase_amount),
|
||||
'weighted_price_rows' => (int) $totals->weighted_price_rows,
|
||||
'missing_weighted_rows' => (int) $totals->missing_weighted_rows,
|
||||
'manual_review_rows' => (int) $totals->manual_review_rows,
|
||||
],
|
||||
'inbound' => $inbound,
|
||||
'source' => [
|
||||
'expected_rows' => $expectedSourceRows,
|
||||
'active_rows' => $actualSourceRows,
|
||||
'row_difference' => $actualSourceRows - $expectedSourceRows,
|
||||
'status' => (string) ($monthState?->status ?? 'missing'),
|
||||
'active_generation' => $generation,
|
||||
'source_filename' => $monthState?->source_filename,
|
||||
],
|
||||
'anomalies' => [
|
||||
'affected_rows' => $anomalyRows,
|
||||
'threshold_rows' => $threshold,
|
||||
'threshold_exceeded' => $anomalyRows > $threshold,
|
||||
'reasons' => $reasons,
|
||||
],
|
||||
'state' => $db->table(self::STATE_TABLE)
|
||||
->where('report_month', $month->toDateString())
|
||||
->first(),
|
||||
'generated_at' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $report */
|
||||
public function brief(array $report): string
|
||||
{
|
||||
if (($report['status'] ?? null) === 'missing') {
|
||||
return (string) ($report['message'] ?? '商品月加权平均单价尚未生成');
|
||||
}
|
||||
|
||||
$totals = $report['totals'] ?? [];
|
||||
$inbound = $report['inbound'] ?? [];
|
||||
$anomalies = $report['anomalies'] ?? [];
|
||||
$reasonLabels = array_map(
|
||||
static fn (array $reason): string =>
|
||||
(string) ($reason['label'] ?? $reason['code'] ?? '未知异常'),
|
||||
$anomalies['reasons'] ?? [],
|
||||
);
|
||||
|
||||
return sprintf(
|
||||
'%s:总行数 %s;期初 %s 件 / %s;入库 %s 件 / %s;加权单价 %s 行;异常 %s 行%s',
|
||||
substr((string) ($report['report_month'] ?? ''), 0, 7),
|
||||
number_format((int) ($totals['rows'] ?? 0)),
|
||||
number_format((float) ($totals['opening_quantity'] ?? 0), 2),
|
||||
number_format((float) ($totals['opening_amount'] ?? 0), 2),
|
||||
number_format((float) ($inbound['matched_quantity'] ?? 0), 2),
|
||||
number_format((float) ($inbound['matched_amount'] ?? 0), 2),
|
||||
number_format((int) ($totals['weighted_price_rows'] ?? 0)),
|
||||
number_format((int) ($anomalies['affected_rows'] ?? 0)),
|
||||
$reasonLabels === [] ? '' : '(' . implode('、', $reasonLabels) . ')',
|
||||
);
|
||||
}
|
||||
|
||||
private function key(mixed $productCode, mixed $warehouseShortName): string
|
||||
{
|
||||
return mb_strtoupper(trim((string) $productCode))
|
||||
. "\0"
|
||||
. trim((string) $warehouseShortName);
|
||||
}
|
||||
|
||||
private function number(float $value): float
|
||||
{
|
||||
return round($value, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $value
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function roundNestedNumbers(array $value): array
|
||||
{
|
||||
foreach ($value as $key => $item) {
|
||||
if (is_array($item)) {
|
||||
$value[$key] = $this->roundNestedNumbers($item);
|
||||
} elseif (is_float($item)) {
|
||||
$value[$key] = round($item, 6);
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
84
app/Business/StoreReceiptPlatformSkuMapper.php
Normal file
84
app/Business/StoreReceiptPlatformSkuMapper.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
/**
|
||||
* 平台数字 SKU → 历史已拆分内部商品编码。
|
||||
*
|
||||
* 平台 shop_sku_id 已包含平台规格维度;仅当同一 shop_sku_id + shop_i_id
|
||||
* 的历史内部编码唯一时才允许替换,多候选保持待核验。
|
||||
*/
|
||||
class StoreReceiptPlatformSkuMapper
|
||||
{
|
||||
/** @var array<string,array<string,int>> */
|
||||
private array $internalSkus = [];
|
||||
|
||||
/** @var array<string,array<string,int>> */
|
||||
private array $internalProperties = [];
|
||||
|
||||
/** @var array<string,int> */
|
||||
private array $historyRows = [];
|
||||
|
||||
/** @param iterable<object|array<string,mixed>> $historyRows */
|
||||
public function __construct(iterable $historyRows)
|
||||
{
|
||||
foreach ($historyRows as $row) {
|
||||
$shopSkuId = trim((string) $this->value($row, 'shop_sku_id'));
|
||||
$shopItemId = trim((string) $this->value($row, 'shop_i_id'));
|
||||
$skuId = trim((string) $this->value($row, 'sku_id'));
|
||||
$itemId = trim((string) $this->value($row, 'i_id'));
|
||||
if ($shopSkuId === '' || $shopItemId === '' || $skuId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 平台原始行的 i_id 等于 shop_i_id;内部拆分行的 i_id 是内部款式编码。
|
||||
if ($itemId === '' || $itemId === $shopItemId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->key($shopSkuId, $shopItemId);
|
||||
$this->internalSkus[$key][$skuId] = ($this->internalSkus[$key][$skuId] ?? 0) + 1;
|
||||
$property = trim((string) $this->value($row, 'properties_value'));
|
||||
if ($property !== '') {
|
||||
$this->internalProperties[$key][$property] = ($this->internalProperties[$key][$property] ?? 0) + 1;
|
||||
}
|
||||
$this->historyRows[$key] = ($this->historyRows[$key] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* status:string,
|
||||
* internal_sku:?string,
|
||||
* candidates:list<string>,
|
||||
* history_rows:int,
|
||||
* internal_properties:list<string>
|
||||
* }
|
||||
*/
|
||||
public function resolve(string $shopSkuId, string $shopItemId): array
|
||||
{
|
||||
$key = $this->key(trim($shopSkuId), trim($shopItemId));
|
||||
$skus = array_keys($this->internalSkus[$key] ?? []);
|
||||
sort($skus);
|
||||
$properties = array_keys($this->internalProperties[$key] ?? []);
|
||||
sort($properties);
|
||||
|
||||
return [
|
||||
'status' => count($skus) === 1 ? 'unique' : ($skus === [] ? 'missing' : 'ambiguous'),
|
||||
'internal_sku' => count($skus) === 1 ? $skus[0] : null,
|
||||
'candidates' => $skus,
|
||||
'history_rows' => (int) ($this->historyRows[$key] ?? 0),
|
||||
'internal_properties' => $properties,
|
||||
];
|
||||
}
|
||||
|
||||
private function key(string $shopSkuId, string $shopItemId): string
|
||||
{
|
||||
return $shopSkuId . "\0" . $shopItemId;
|
||||
}
|
||||
|
||||
private function value(object|array $row, string $key): mixed
|
||||
{
|
||||
return is_array($row) ? ($row[$key] ?? null) : ($row->{$key} ?? null);
|
||||
}
|
||||
}
|
||||
@ -4,11 +4,13 @@ namespace App\Business;
|
||||
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\StoreReportPeriodBuilder;
|
||||
|
||||
class StoreReceivingAccountSynchronizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BusinessInformationMirrorRepository $mirror,
|
||||
private readonly StoreReportPeriodBuilder $storeReportPeriods,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -35,6 +37,15 @@ class StoreReceivingAccountSynchronizer
|
||||
$byWallet = [];
|
||||
$byPlatformAndName = [];
|
||||
$byName = [];
|
||||
$sourceStores = $this->mirror->receivingAccountStores();
|
||||
// 店铺主体跟随店铺维度报表的“存续中”期间,不再直接取店铺档案当前主体。
|
||||
$currentCompanies = $this->storeReportPeriods->currentCompanies(array_map(
|
||||
static fn (array $row): array => [
|
||||
'erp_store_name' => trim((string) ($row['erp_store_name'] ?? '')),
|
||||
'system_company_name' => trim((string) ($row['company_name'] ?? '')),
|
||||
],
|
||||
$sourceStores,
|
||||
));
|
||||
|
||||
foreach ($existing as $account) {
|
||||
$platform = (string) $account->store_platform;
|
||||
@ -49,7 +60,7 @@ class StoreReceivingAccountSynchronizer
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->mirror->receivingAccountStores() as $row) {
|
||||
foreach ($sourceStores as $row) {
|
||||
$erpStoreName = trim((string) ($row['erp_store_name'] ?? ''));
|
||||
$platform = $this->classify($erpStoreName, (string) ($row['source_platform'] ?? ''));
|
||||
|
||||
@ -75,7 +86,8 @@ class StoreReceivingAccountSynchronizer
|
||||
'company_id' => $companyId,
|
||||
'store_platform' => $platform,
|
||||
'store_name' => $erpStoreName,
|
||||
'store_entity_name' => trim((string) ($row['company_name'] ?? '')),
|
||||
'store_entity_name' => $currentCompanies[$erpStoreName]
|
||||
?? trim((string) ($row['company_name'] ?? '')),
|
||||
'shop_name' => trim((string) ($row['shop_name'] ?? '')) ?: $erpStoreName,
|
||||
'store_status' => $this->status($row['store_status'] ?? null),
|
||||
'wallet_number' => $storeId,
|
||||
|
||||
939
app/Business/SupplierSummaryInboundImporter.php
Normal file
939
app/Business/SupplierSummaryInboundImporter.php
Normal file
@ -0,0 +1,939 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use DateTimeImmutable;
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use XMLReader;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* 供应商汇总表月度增量导入。
|
||||
*
|
||||
* 仅追加目标月份的新行,不删除其他月份。内容相同的源明细按出现次序分配
|
||||
* 稳定标识后全部保留;重复执行时只跳过已经写入的同一源行。
|
||||
* 单行字段异常会进入失败清单,不阻断其他有效行。
|
||||
*/
|
||||
class SupplierSummaryInboundImporter
|
||||
{
|
||||
private const TABLE = 'inventory_inbound_detail_rows';
|
||||
|
||||
private const DETAIL_SHEET = '明细';
|
||||
|
||||
/** @var array<string,list<string>> */
|
||||
private const REQUIRED_HEADERS = [
|
||||
'source_no' => ['来源单号'],
|
||||
'document_no' => ['单据编号(采购单号)', '单据编号(采购单号)', '单据编号'],
|
||||
'supplier' => ['供应商'],
|
||||
'item_no' => ['货号'],
|
||||
'barcode' => ['条码'],
|
||||
'spec_name' => ['规格名'],
|
||||
'quantity' => ['库存变动量'],
|
||||
'unit_price' => ['单价'],
|
||||
'amount' => ['金额'],
|
||||
'estimated_unit_price' => ['预估单价'],
|
||||
'estimated_amount' => ['预估金额'],
|
||||
'supplier_barcode' => ['供应商条码'],
|
||||
'remark' => ['备注'],
|
||||
'warehouse' => ['仓库'],
|
||||
];
|
||||
|
||||
/** @var array<string,list<string>> */
|
||||
private const OPTIONAL_HEADERS = [
|
||||
'express_name' => ['快递名称'],
|
||||
'express_no' => ['快递单号'],
|
||||
'not_sent' => ['未发出'],
|
||||
'owner_name' => ['负责人'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* status:string,
|
||||
* report_month:string,
|
||||
* source_filename:string,
|
||||
* success:int,
|
||||
* duplicates:int,
|
||||
* failed:int,
|
||||
* mapped:int,
|
||||
* total_in_table:int,
|
||||
* failure_reason_count:int,
|
||||
* failure_reasons:list<array{row:int,reason:string}>
|
||||
* }
|
||||
*/
|
||||
public function import(string $file, string $reportMonth): array
|
||||
{
|
||||
$path = realpath($file);
|
||||
if ($path === false || ! is_file($path) || ! is_readable($path)) {
|
||||
throw new RuntimeException('供应商汇总文件不存在或不可读取。');
|
||||
}
|
||||
|
||||
$month = $this->normalizeMonth($reportMonth);
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$maps = $this->shortNameMap();
|
||||
|
||||
[$reader, $sharedStrings] = $this->streamDetailSheet($path);
|
||||
|
||||
try {
|
||||
$success = 0;
|
||||
$duplicates = 0;
|
||||
$mapped = 0;
|
||||
$failed = 0;
|
||||
$reasons = [];
|
||||
$occurrences = [];
|
||||
$batch = [];
|
||||
|
||||
DB::connection($connection)->transaction(function () use (
|
||||
$connection,
|
||||
$reader,
|
||||
$sharedStrings,
|
||||
$maps,
|
||||
$month,
|
||||
&$success,
|
||||
&$duplicates,
|
||||
&$mapped,
|
||||
&$failed,
|
||||
&$reasons,
|
||||
&$occurrences,
|
||||
&$batch,
|
||||
): void {
|
||||
$flush = function () use (
|
||||
$connection,
|
||||
$month,
|
||||
&$success,
|
||||
&$duplicates,
|
||||
&$batch,
|
||||
): void {
|
||||
if ($batch === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$keys = array_column($batch, 'row_key');
|
||||
$existing = DB::connection($connection)
|
||||
->table(self::TABLE)
|
||||
->where('report_month', $month)
|
||||
->whereIn('row_key', $keys)
|
||||
->pluck('row_key')
|
||||
->map(static fn (mixed $key): string => (string) $key)
|
||||
->all();
|
||||
$existing = array_fill_keys($existing, true);
|
||||
|
||||
$insertable = [];
|
||||
foreach ($batch as $record) {
|
||||
if (isset($existing[$record['row_key']])) {
|
||||
$duplicates++;
|
||||
continue;
|
||||
}
|
||||
$insertable[] = $record;
|
||||
}
|
||||
|
||||
if ($insertable !== []) {
|
||||
$inserted = DB::connection($connection)
|
||||
->table(self::TABLE)
|
||||
->insertOrIgnore($insertable);
|
||||
$success += (int) $inserted;
|
||||
$duplicates += count($insertable) - (int) $inserted;
|
||||
}
|
||||
$batch = [];
|
||||
};
|
||||
|
||||
$headerRow = null;
|
||||
$columns = [];
|
||||
$headerCandidates = [];
|
||||
|
||||
while ($reader->read()) {
|
||||
if (
|
||||
$reader->nodeType !== XMLReader::ELEMENT
|
||||
|| $reader->name !== 'row'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowNumber = (int) $reader->getAttribute('r');
|
||||
$values = $this->readRow($reader, $sharedStrings);
|
||||
|
||||
if ($headerRow === null) {
|
||||
$headerCandidates[$rowNumber] = $values;
|
||||
$header = $this->headerColumns($headerCandidates);
|
||||
if ($header !== null) {
|
||||
[$headerRow, $columns] = $header;
|
||||
$headerCandidates = [];
|
||||
continue;
|
||||
}
|
||||
if ($rowNumber >= 20) {
|
||||
throw new RuntimeException(
|
||||
'「明细」子表前 20 行未找到供应商汇总标准表头。',
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($rowNumber <= $headerRow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->emptyRow($values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$record = $this->record(
|
||||
$values,
|
||||
$columns,
|
||||
$maps,
|
||||
$month,
|
||||
$rowNumber,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$failed++;
|
||||
$reasons[] = [
|
||||
'row' => $rowNumber,
|
||||
'reason' => $exception->getMessage(),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$baseKey = (string) $record['row_key'];
|
||||
$occurrence = ($occurrences[$baseKey] ?? 0) + 1;
|
||||
$occurrences[$baseKey] = $occurrence;
|
||||
if ($occurrence > 1) {
|
||||
$record['row_key'] = hash(
|
||||
'sha256',
|
||||
$baseKey . '|occurrence:' . $occurrence,
|
||||
);
|
||||
}
|
||||
$mapped++;
|
||||
$batch[] = $record;
|
||||
|
||||
if (count($batch) >= 500) {
|
||||
$flush();
|
||||
}
|
||||
}
|
||||
|
||||
if ($headerRow === null) {
|
||||
throw new RuntimeException(
|
||||
'「明细」子表前 20 行未找到供应商汇总标准表头。',
|
||||
);
|
||||
}
|
||||
|
||||
$flush();
|
||||
});
|
||||
|
||||
$status = $failed === 0
|
||||
? 'success'
|
||||
: ($success > 0 ? 'partial' : 'failed');
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'report_month' => $month,
|
||||
'source_filename' => basename($path),
|
||||
'success' => $success,
|
||||
'duplicates' => $duplicates,
|
||||
'failed' => $failed,
|
||||
'mapped' => $mapped,
|
||||
'total_in_table' => DB::connection($connection)
|
||||
->table(self::TABLE)
|
||||
->where('report_month', $month)
|
||||
->count(),
|
||||
'failure_reason_count' => count($reasons),
|
||||
'failure_reasons' => $reasons,
|
||||
];
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeMonth(string $month): string
|
||||
{
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
|
||||
throw new RuntimeException('月份格式应为 YYYY-MM。');
|
||||
}
|
||||
|
||||
return $month . '-01';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<int,mixed>> $rows
|
||||
* @return array{0:int,1:array<string,int>}|null
|
||||
*/
|
||||
private function headerColumns(array $rows): ?array
|
||||
{
|
||||
ksort($rows);
|
||||
foreach ($rows as $row => $values) {
|
||||
$positions = [];
|
||||
foreach ($values as $index => $value) {
|
||||
$heading = $this->normalizeHeader(
|
||||
(string) $this->plainValue($value),
|
||||
);
|
||||
if ($heading !== '') {
|
||||
$positions[$heading][] = $index;
|
||||
}
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
foreach (self::REQUIRED_HEADERS as $field => $aliases) {
|
||||
$position = $this->firstHeaderPosition($positions, $aliases);
|
||||
if ($position !== null) {
|
||||
$columns[$field] = $position;
|
||||
}
|
||||
}
|
||||
|
||||
$createdPositions = $positions[$this->normalizeHeader('创建时间')] ?? [];
|
||||
if (count($createdPositions) >= 2) {
|
||||
$columns['created_serial'] = $createdPositions[0];
|
||||
$columns['created_at_raw'] = $createdPositions[1];
|
||||
}
|
||||
|
||||
if (count($columns) < count(self::REQUIRED_HEADERS) + 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (self::OPTIONAL_HEADERS as $field => $aliases) {
|
||||
$position = $this->firstHeaderPosition($positions, $aliases);
|
||||
if ($position !== null) {
|
||||
$columns[$field] = $position;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['created_serial', 'created_at_raw'] as $field) {
|
||||
if (! isset($columns[$field])) {
|
||||
throw new RuntimeException('「明细」表头缺少第二个「创建时间」列。');
|
||||
}
|
||||
}
|
||||
|
||||
return [$row, $columns];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<string,list<int>> $positions @param list<string> $aliases */
|
||||
private function firstHeaderPosition(array $positions, array $aliases): ?int
|
||||
{
|
||||
foreach ($aliases as $alias) {
|
||||
$heading = $this->normalizeHeader($alias);
|
||||
if (isset($positions[$heading][0])) {
|
||||
return $positions[$heading][0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeHeader(string $heading): string
|
||||
{
|
||||
return preg_replace('/[\s\x{3000}]+/u', '', trim($heading)) ?? trim($heading);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $values
|
||||
* @param array<string,int> $columns
|
||||
* @param array{warehouse:array<string,string>,provider:array<string,string>} $maps
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function record(
|
||||
array $values,
|
||||
array $columns,
|
||||
array $maps,
|
||||
string $month,
|
||||
int $rowNumber,
|
||||
): array {
|
||||
$text = fn (string $field): ?string => $this->text(
|
||||
$values[$columns[$field] ?? -1] ?? null,
|
||||
);
|
||||
$number = function (string $label, string $field) use (
|
||||
$values,
|
||||
$columns,
|
||||
$rowNumber,
|
||||
): ?float {
|
||||
$value = $values[$columns[$field] ?? -1] ?? null;
|
||||
if (is_array($value)) {
|
||||
$formula = trim((string) ($value['__formula'] ?? ''));
|
||||
if ($formula !== '') {
|
||||
return $this->formulaValue(
|
||||
$label,
|
||||
$field,
|
||||
$formula,
|
||||
$rowNumber,
|
||||
$values,
|
||||
$columns,
|
||||
);
|
||||
}
|
||||
$value = $value['__cached'] ?? null;
|
||||
}
|
||||
|
||||
$text = str_replace(
|
||||
[',', ','],
|
||||
'',
|
||||
trim((string) $this->plainValue($value)),
|
||||
);
|
||||
if ($text === '') {
|
||||
return null;
|
||||
}
|
||||
if (! is_numeric($text)) {
|
||||
throw new RuntimeException("字段「{$label}」不是有效数字:" . $text);
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
};
|
||||
|
||||
$barcode = $text('barcode');
|
||||
$warehouse = $text('warehouse');
|
||||
if ($barcode === null) {
|
||||
throw new RuntimeException('商品编码(条码)为空');
|
||||
}
|
||||
if ($warehouse === null) {
|
||||
throw new RuntimeException('仓库为空');
|
||||
}
|
||||
|
||||
$shortName = $maps['warehouse'][$warehouse]
|
||||
?? $maps['provider'][$warehouse]
|
||||
?? null;
|
||||
if ($shortName === null || trim($shortName) === '') {
|
||||
throw new RuntimeException('仓库未匹配到唯一仓库简称:' . $warehouse);
|
||||
}
|
||||
|
||||
$createdRaw = $this->moment($values[$columns['created_at_raw']] ?? null);
|
||||
if ($createdRaw === null) {
|
||||
throw new RuntimeException('第二个「创建时间」为空或格式无效');
|
||||
}
|
||||
|
||||
$record = [
|
||||
'report_month' => $month,
|
||||
'source_no' => $text('source_no'),
|
||||
'document_no' => $text('document_no'),
|
||||
'created_serial' => $text('created_serial'),
|
||||
'supplier' => $text('supplier'),
|
||||
'item_no' => $text('item_no'),
|
||||
'barcode' => mb_strtoupper($barcode),
|
||||
'spec_name' => $text('spec_name'),
|
||||
'quantity' => $number('库存变动量', 'quantity'),
|
||||
'unit_price' => $number('单价', 'unit_price'),
|
||||
'amount' => $number('金额', 'amount'),
|
||||
'estimated_unit_price' => $number('预估单价', 'estimated_unit_price'),
|
||||
'estimated_amount' => $number('预估金额', 'estimated_amount'),
|
||||
'supplier_barcode' => $text('supplier_barcode'),
|
||||
'remark' => $text('remark'),
|
||||
'warehouse' => $warehouse,
|
||||
'warehouse_short_name' => $shortName,
|
||||
'created_at_raw' => $createdRaw,
|
||||
'express_name' => $text('express_name'),
|
||||
'express_no' => $text('express_no'),
|
||||
'not_sent' => $text('not_sent'),
|
||||
'owner_name' => $text('owner_name'),
|
||||
];
|
||||
|
||||
unset($record['warehouse_short_name']);
|
||||
$record['row_key'] = hash(
|
||||
'sha256',
|
||||
json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
);
|
||||
$record['warehouse_short_name'] = $shortName;
|
||||
$record['imported_at'] = now();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅接受金额类字段当前行内的乘积公式,避免执行来源文件中的任意公式。
|
||||
*
|
||||
* @param array<int,mixed> $values
|
||||
* @param array<string,int> $columns
|
||||
*/
|
||||
private function formulaValue(
|
||||
string $label,
|
||||
string $field,
|
||||
string $formula,
|
||||
int $rowNumber,
|
||||
array $values,
|
||||
array $columns,
|
||||
): float {
|
||||
$expectedFields = match ($field) {
|
||||
'amount' => ['quantity', 'unit_price'],
|
||||
'estimated_amount' => ['estimated_unit_price', 'quantity'],
|
||||
default => [],
|
||||
};
|
||||
if ($expectedFields === []) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」不允许使用公式:" . $formula,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
preg_match(
|
||||
'/^=?\s*\$?([A-Z]{1,3})\$?(\d+)\s*\*\s*\$?([A-Z]{1,3})\$?(\d+)\s*$/i',
|
||||
$formula,
|
||||
$matches,
|
||||
) !== 1
|
||||
) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」公式无法安全识别:" . $formula,
|
||||
);
|
||||
}
|
||||
|
||||
if ((int) $matches[2] !== $rowNumber || (int) $matches[4] !== $rowNumber) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」公式引用了其他行:" . $formula,
|
||||
);
|
||||
}
|
||||
|
||||
$actualColumns = [
|
||||
$this->columnIndex($matches[1]),
|
||||
$this->columnIndex($matches[3]),
|
||||
];
|
||||
$expectedColumns = [
|
||||
$columns[$expectedFields[0]] + 1,
|
||||
$columns[$expectedFields[1]] + 1,
|
||||
];
|
||||
sort($actualColumns);
|
||||
sort($expectedColumns);
|
||||
if ($actualColumns !== $expectedColumns) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」公式与取数口径不一致:" . $formula,
|
||||
);
|
||||
}
|
||||
|
||||
$left = $this->numericReference(
|
||||
$values[$actualColumns[0] - 1] ?? null,
|
||||
$label,
|
||||
);
|
||||
$right = $this->numericReference(
|
||||
$values[$actualColumns[1] - 1] ?? null,
|
||||
$label,
|
||||
);
|
||||
|
||||
return $left * $right;
|
||||
}
|
||||
|
||||
private function numericReference(mixed $value, string $label): float
|
||||
{
|
||||
if (is_array($value)) {
|
||||
if (($value['__formula'] ?? '') !== '') {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」的公式引用了另一个公式单元格",
|
||||
);
|
||||
}
|
||||
$value = $value['__cached'] ?? null;
|
||||
}
|
||||
|
||||
$text = str_replace(
|
||||
[',', ','],
|
||||
'',
|
||||
trim((string) $this->plainValue($value)),
|
||||
);
|
||||
if (! is_numeric($text)) {
|
||||
throw new RuntimeException(
|
||||
"字段「{$label}」的公式引用值不是有效数字:" . $text,
|
||||
);
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
}
|
||||
|
||||
/** @param array<int,mixed> $values */
|
||||
private function emptyRow(array $values): bool
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
if (trim((string) $this->plainValue($value)) !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function text(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) $this->plainValue($value));
|
||||
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function plainValue(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value['__cached'] ?? null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function moment(mixed $value): ?string
|
||||
{
|
||||
$value = $this->plainValue($value);
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return null;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
try {
|
||||
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d H:i:s');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
|
||||
$date = DateTimeImmutable::createFromFormat($format, $value);
|
||||
if ($date instanceof DateTimeImmutable) {
|
||||
return $date->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
|
||||
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
|
||||
private function shortNameMap(): array
|
||||
{
|
||||
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$warehouse = trim((string) $row->warehouse);
|
||||
if ($warehouse !== '') {
|
||||
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
}
|
||||
|
||||
$byWarehouse = [];
|
||||
foreach ($grouped as $warehouse => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byWarehouse[$warehouse] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
$providerGrouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$provider = trim((string) $row->warehouse_provider);
|
||||
if ($provider !== '') {
|
||||
$providerGrouped[$provider][] = [
|
||||
'warehouse' => trim((string) $row->warehouse),
|
||||
'short_name' => trim((string) $row->warehouse_short_name),
|
||||
];
|
||||
}
|
||||
}
|
||||
$byProvider = [];
|
||||
foreach ($providerGrouped as $provider => $providerRows) {
|
||||
$unique = array_values(array_unique(array_column(
|
||||
$providerRows,
|
||||
'short_name',
|
||||
)));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byProvider[$provider] = $unique[0];
|
||||
continue;
|
||||
}
|
||||
|
||||
$mainShortNames = [];
|
||||
foreach ($providerRows as $providerRow) {
|
||||
if (str_contains($providerRow['warehouse'], '主仓')) {
|
||||
$mainShortNames[] = $providerRow['short_name'];
|
||||
}
|
||||
}
|
||||
$mainShortNames = array_values(array_unique($mainShortNames));
|
||||
if (count($mainShortNames) === 1 && $mainShortNames[0] !== '') {
|
||||
$byProvider[$provider] = $mainShortNames[0];
|
||||
}
|
||||
}
|
||||
|
||||
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
|
||||
}
|
||||
|
||||
/** @return array{0:XMLReader,1:list<string>} */
|
||||
private function streamDetailSheet(string $path): array
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new RuntimeException('供应商汇总文件不是有效的 XLSX 工作簿。');
|
||||
}
|
||||
|
||||
try {
|
||||
$sheetPath = $this->detailSheetPath($zip);
|
||||
$sharedStrings = $zip->locateName('xl/sharedStrings.xml') !== false
|
||||
? $this->readSharedStrings($path)
|
||||
: [];
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
return [
|
||||
$this->openZipEntry($path, $sheetPath),
|
||||
$sharedStrings,
|
||||
];
|
||||
}
|
||||
|
||||
private function detailSheetPath(ZipArchive $zip): string
|
||||
{
|
||||
$workbookXml = $zip->getFromName('xl/workbook.xml');
|
||||
$relationshipsXml = $zip->getFromName('xl/_rels/workbook.xml.rels');
|
||||
if ($workbookXml === false || $relationshipsXml === false) {
|
||||
throw new RuntimeException('供应商汇总工作簿缺少工作簿结构文件。');
|
||||
}
|
||||
|
||||
$workbook = new DOMDocument();
|
||||
$relationships = new DOMDocument();
|
||||
if (
|
||||
! $workbook->loadXML($workbookXml, LIBXML_NONET)
|
||||
|| ! $relationships->loadXML($relationshipsXml, LIBXML_NONET)
|
||||
) {
|
||||
throw new RuntimeException('供应商汇总工作簿结构无法读取。');
|
||||
}
|
||||
|
||||
$relationshipId = null;
|
||||
$xpath = new DOMXPath($workbook);
|
||||
$xpath->registerNamespace(
|
||||
'main',
|
||||
'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
|
||||
);
|
||||
$xpath->registerNamespace(
|
||||
'office',
|
||||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
);
|
||||
foreach ($xpath->query('/main:workbook/main:sheets/main:sheet') ?: [] as $sheet) {
|
||||
if ($sheet instanceof \DOMElement
|
||||
&& $sheet->getAttribute('name') === self::DETAIL_SHEET
|
||||
) {
|
||||
$relationshipId = $sheet->getAttributeNS(
|
||||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
||||
'id',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($relationshipId === null || $relationshipId === '') {
|
||||
throw new RuntimeException('供应商汇总表缺少「明细」子表。');
|
||||
}
|
||||
|
||||
$target = null;
|
||||
foreach ($relationships->getElementsByTagName('Relationship') as $relationship) {
|
||||
if ($relationship->getAttribute('Id') === $relationshipId) {
|
||||
$target = $relationship->getAttribute('Target');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($target === null || $target === '') {
|
||||
throw new RuntimeException('「明细」子表的工作簿关系不存在。');
|
||||
}
|
||||
|
||||
return $this->normalizeZipPath(
|
||||
str_starts_with($target, '/') ? ltrim($target, '/') : 'xl/' . $target,
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeZipPath(string $path): string
|
||||
{
|
||||
$segments = [];
|
||||
foreach (explode('/', str_replace('\\', '/', $path)) as $segment) {
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($segment === '..') {
|
||||
array_pop($segments);
|
||||
continue;
|
||||
}
|
||||
$segments[] = $segment;
|
||||
}
|
||||
|
||||
return implode('/', $segments);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function readSharedStrings(string $path): array
|
||||
{
|
||||
$reader = $this->openZipEntry($path, 'xl/sharedStrings.xml');
|
||||
$strings = [];
|
||||
$current = null;
|
||||
$stringDepth = null;
|
||||
$phoneticDepth = null;
|
||||
|
||||
try {
|
||||
while ($reader->read()) {
|
||||
if (
|
||||
$reader->nodeType === XMLReader::ELEMENT
|
||||
&& $reader->name === 'si'
|
||||
) {
|
||||
$current = '';
|
||||
$stringDepth = $reader->depth;
|
||||
$phoneticDepth = null;
|
||||
continue;
|
||||
}
|
||||
if ($current === null) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::ELEMENT
|
||||
&& $reader->name === 'rPh'
|
||||
) {
|
||||
$phoneticDepth = $reader->depth;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'rPh'
|
||||
&& $reader->depth === $phoneticDepth
|
||||
) {
|
||||
$phoneticDepth = null;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::ELEMENT
|
||||
&& $reader->name === 't'
|
||||
&& $phoneticDepth === null
|
||||
) {
|
||||
$current .= $reader->readString();
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'si'
|
||||
&& $reader->depth === $stringDepth
|
||||
) {
|
||||
$strings[] = $current;
|
||||
$current = null;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$reader->close();
|
||||
}
|
||||
|
||||
return $strings;
|
||||
}
|
||||
|
||||
private function openZipEntry(string $path, string $entry): XMLReader
|
||||
{
|
||||
$reader = new XMLReader();
|
||||
$flags = LIBXML_NONET | LIBXML_COMPACT;
|
||||
if (! $reader->open("zip://{$path}#{$entry}", null, $flags)) {
|
||||
throw new RuntimeException("无法读取工作簿内容:{$entry}");
|
||||
}
|
||||
|
||||
return $reader;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $sharedStrings
|
||||
* @return array<int,mixed>
|
||||
*/
|
||||
private function readRow(XMLReader $reader, array $sharedStrings): array
|
||||
{
|
||||
$values = [];
|
||||
$rowDepth = $reader->depth;
|
||||
while ($reader->read()) {
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'row'
|
||||
&& $reader->depth === $rowDepth
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
$reader->nodeType !== XMLReader::ELEMENT
|
||||
|| $reader->name !== 'c'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reference = $reader->getAttribute('r') ?? '';
|
||||
if (
|
||||
preg_match('/^([A-Z]{1,3})\d+$/i', $reference, $matches) !== 1
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
$values[$this->columnIndex($matches[1]) - 1] = $this->readCell(
|
||||
$reader,
|
||||
$sharedStrings,
|
||||
);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/** @param list<string> $sharedStrings */
|
||||
private function readCell(XMLReader $reader, array $sharedStrings): mixed
|
||||
{
|
||||
if ($reader->isEmptyElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $reader->getAttribute('t') ?? '';
|
||||
$cellDepth = $reader->depth;
|
||||
$cached = null;
|
||||
$formula = null;
|
||||
$hasFormula = false;
|
||||
$inlineParts = [];
|
||||
$phoneticDepth = null;
|
||||
|
||||
while ($reader->read()) {
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'c'
|
||||
&& $reader->depth === $cellDepth
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if ($reader->nodeType === XMLReader::ELEMENT) {
|
||||
if ($reader->name === 'f') {
|
||||
$hasFormula = true;
|
||||
$formula = $reader->readString();
|
||||
continue;
|
||||
}
|
||||
if ($reader->name === 'v') {
|
||||
$cached = $reader->readString();
|
||||
continue;
|
||||
}
|
||||
if ($reader->name === 'rPh') {
|
||||
$phoneticDepth = $reader->depth;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
$reader->name === 't'
|
||||
&& $phoneticDepth === null
|
||||
) {
|
||||
$inlineParts[] = $reader->readString();
|
||||
}
|
||||
}
|
||||
if (
|
||||
$reader->nodeType === XMLReader::END_ELEMENT
|
||||
&& $reader->name === 'rPh'
|
||||
&& $reader->depth === $phoneticDepth
|
||||
) {
|
||||
$phoneticDepth = null;
|
||||
}
|
||||
}
|
||||
|
||||
$value = match ($type) {
|
||||
's' => is_numeric($cached)
|
||||
? ($sharedStrings[(int) $cached] ?? null)
|
||||
: null,
|
||||
'inlineStr' => implode('', $inlineParts),
|
||||
'b' => $cached === null ? null : ($cached === '1' ? '1' : '0'),
|
||||
default => $cached ?? implode('', $inlineParts),
|
||||
};
|
||||
|
||||
if ($hasFormula) {
|
||||
return [
|
||||
'__formula' => (string) $formula,
|
||||
'__cached' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function columnIndex(string $column): int
|
||||
{
|
||||
$index = 0;
|
||||
foreach (str_split(strtoupper($column)) as $letter) {
|
||||
$index = ($index * 26) + (ord($letter) - 64);
|
||||
}
|
||||
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
249
app/Business/SystemizationTodoWorkbookImporter.php
Normal file
249
app/Business/SystemizationTodoWorkbookImporter.php
Normal file
@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\SystemizationTodo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Services\PayablesWorkbookStreamReader;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
|
||||
use RuntimeException;
|
||||
|
||||
class SystemizationTodoWorkbookImporter
|
||||
{
|
||||
private const REQUIRED_HEADERS = [
|
||||
'category' => '类型',
|
||||
'section' => '内容',
|
||||
'title' => '待办事项',
|
||||
'owner' => '总负责人',
|
||||
'assignee' => '责任人',
|
||||
'start' => '开始时间',
|
||||
'finish' => '完成时间',
|
||||
'progress' => '完成情况',
|
||||
'notes' => '备注',
|
||||
];
|
||||
|
||||
/**
|
||||
* 导入财务系统化待办。只新增系统中不存在的来源行,不覆盖日常维护后的状态与截图。
|
||||
*
|
||||
* @return array{created:int,skipped:int,sheet:string}
|
||||
*/
|
||||
public function import(string $path, int $companyId, ?int $userId = null): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException('找不到待办 Excel 文件。');
|
||||
}
|
||||
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
if ($extension !== 'xlsx') {
|
||||
throw new RuntimeException('系统化待办仅支持 .xlsx 文件。');
|
||||
}
|
||||
|
||||
$reader = PayablesWorkbookStreamReader::forFile($path, $extension);
|
||||
foreach ($reader->sheetNames() as $sheetIndex => $sheetName) {
|
||||
$headerInfo = $this->headerMap($reader, $sheetIndex);
|
||||
if ($headerInfo === null) {
|
||||
continue;
|
||||
}
|
||||
$header = $headerInfo['columns'];
|
||||
$headerRow = $headerInfo['row'];
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
DB::transaction(function () use (
|
||||
$reader,
|
||||
$sheetIndex,
|
||||
$sheetName,
|
||||
$header,
|
||||
$headerRow,
|
||||
$companyId,
|
||||
$userId,
|
||||
&$created,
|
||||
&$skipped
|
||||
): void {
|
||||
$category = '';
|
||||
$section = '';
|
||||
foreach ($reader->rows($sheetIndex) as $rowNumber => $cells) {
|
||||
if ($rowNumber <= $headerRow) {
|
||||
continue;
|
||||
}
|
||||
$categoryValue = $this->cell($cells, $header['category'] ?? null);
|
||||
if ($categoryValue !== '') {
|
||||
$category = $categoryValue;
|
||||
}
|
||||
$sectionValue = $this->cell($cells, $header['section'] ?? null);
|
||||
if ($sectionValue !== '') {
|
||||
$section = $sectionValue;
|
||||
}
|
||||
|
||||
$title = $this->cell($cells, $header['title'] ?? null);
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$owner = $this->cell($cells, $header['owner'] ?? null);
|
||||
$assignee = $this->cell($cells, $header['assignee'] ?? null);
|
||||
$sourceKey = hash(
|
||||
'sha256',
|
||||
implode('|', [$companyId, $sheetName, $rowNumber, $category, $section, $title, $owner, $assignee])
|
||||
);
|
||||
$exists = SystemizationTodo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->where('source_key', $sourceKey)
|
||||
->exists();
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$startText = $this->cell($cells, $header['start'] ?? null);
|
||||
$finishText = $this->cell($cells, $header['finish'] ?? null);
|
||||
[$startDate, $startNote] = $this->dateParts($startText);
|
||||
[$finishDate, $finishNote] = $this->dateParts($finishText);
|
||||
$progress = $this->cell($cells, $header['progress'] ?? null);
|
||||
$status = $this->status($progress, $startText, $finishText);
|
||||
$completedAt = $status === SystemizationTodo::STATUS_COMPLETED
|
||||
? ($finishDate ?? now()->toDateString())
|
||||
: null;
|
||||
|
||||
SystemizationTodo::withoutGlobalScopes()->create([
|
||||
'company_id' => $companyId,
|
||||
'category' => $category !== '' ? $category : null,
|
||||
'section' => $section !== '' ? $section : null,
|
||||
'title' => $title,
|
||||
'owner' => $owner !== '' ? $owner : null,
|
||||
'assignee' => $assignee !== '' ? $assignee : null,
|
||||
'start_date' => $startDate,
|
||||
'start_note' => $startNote !== '' ? $startNote : null,
|
||||
'due_date' => $finishDate,
|
||||
'due_note' => $finishNote !== '' ? $finishNote : null,
|
||||
'status' => $status,
|
||||
'completed_at' => $completedAt,
|
||||
'progress' => $progress !== '' ? $progress : null,
|
||||
'notes' => ($notes = $this->cell($cells, $header['notes'] ?? null)) !== '' ? $notes : null,
|
||||
'sort_order' => $rowNumber,
|
||||
'source_key' => $sourceKey,
|
||||
'imported_at' => now(),
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
$created++;
|
||||
}
|
||||
});
|
||||
|
||||
return ['created' => $created, 'skipped' => $skipped, 'sheet' => $sheetName];
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{row:int,columns:array<string,int>}|null
|
||||
*/
|
||||
private function headerMap(PayablesWorkbookStreamReader $reader, int $sheetIndex): ?array
|
||||
{
|
||||
foreach ($reader->rows($sheetIndex, null, 12) as $rowNumber => $cells) {
|
||||
$normalized = [];
|
||||
foreach ($cells as $column => $value) {
|
||||
$value = $this->normalizeHeader($this->text($value));
|
||||
if ($value !== '') {
|
||||
$normalized[$value] = $column;
|
||||
}
|
||||
}
|
||||
|
||||
$mapped = [];
|
||||
foreach (self::REQUIRED_HEADERS as $field => $label) {
|
||||
$needle = $this->normalizeHeader($label);
|
||||
foreach ($normalized as $header => $column) {
|
||||
if ($header === $needle || str_starts_with($header, $needle)) {
|
||||
$mapped[$field] = $column;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($mapped['title'])
|
||||
&& isset($mapped['owner'])
|
||||
&& isset($mapped['assignee'])) {
|
||||
return ['row' => $rowNumber, 'columns' => $mapped];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeHeader(string $value): string
|
||||
{
|
||||
return preg_replace('/[\s ()()]+/u', '', $value) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $cells
|
||||
*/
|
||||
private function cell(array $cells, ?int $column): string
|
||||
{
|
||||
return $column === null ? '' : $this->text($cells[$column] ?? null);
|
||||
}
|
||||
|
||||
private function text(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
return trim(str_replace("\u{00A0}", ' ', (string) $value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string|null,1:string}
|
||||
*/
|
||||
private function dateParts(string $value): array
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '' || $value === '已完成') {
|
||||
return [null, ''];
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
$serial = (float) $value;
|
||||
if ($serial >= 20000 && $serial <= 80000) {
|
||||
return [ExcelDate::excelToDateTimeObject($serial)->format('Y-m-d'), ''];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['Y-m-d', 'Y/m/d', 'Y.m.d', 'Y年n月j日'] as $format) {
|
||||
try {
|
||||
$date = Carbon::createFromFormat($format, $value);
|
||||
if ($date !== false) {
|
||||
return [$date->format('Y-m-d'), ''];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// 不是标准日期,保留原值作为时间备注。
|
||||
}
|
||||
}
|
||||
|
||||
return [null, mb_substr($value, 0, 100)];
|
||||
}
|
||||
|
||||
private function status(string $progress, string $startNote, string $finishNote): string
|
||||
{
|
||||
if (str_contains($progress, '已完成')) {
|
||||
return SystemizationTodo::STATUS_COMPLETED;
|
||||
}
|
||||
if ($progress !== '') {
|
||||
return str_contains($progress, '延期')
|
||||
? SystemizationTodo::STATUS_DELAYED
|
||||
: SystemizationTodo::STATUS_IN_PROGRESS;
|
||||
}
|
||||
if (str_contains($startNote, '延期') || str_contains($finishNote, '延期')) {
|
||||
return SystemizationTodo::STATUS_DELAYED;
|
||||
}
|
||||
|
||||
return SystemizationTodo::STATUS_PENDING;
|
||||
}
|
||||
}
|
||||
238
app/Business/WithdrawalBalanceAlertSynchronizer.php
Normal file
238
app/Business/WithdrawalBalanceAlertSynchronizer.php
Normal file
@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use App\Models\Business\Todo;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
||||
|
||||
class WithdrawalBalanceAlertSynchronizer
|
||||
{
|
||||
public const CATEGORY = '提现余额预警';
|
||||
|
||||
public const TODO_STATUS = '待处理';
|
||||
|
||||
public function __construct(
|
||||
private readonly JushuitanShopBalanceRepository $shopBalances,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the latest balance date for a company and persist one open todo
|
||||
* per store that has exceeded its reserve standard without any change for
|
||||
* three consecutive calendar days.
|
||||
*
|
||||
* @return array{balance_date:string,dates:list<string>,alerts:array<int,array<string,mixed>>,created:int,updated:int}
|
||||
*/
|
||||
public function syncLatest(int $companyId): array
|
||||
{
|
||||
$latest = $this->shopBalances->latestBalanceDate(now());
|
||||
if ($latest === null) {
|
||||
return [
|
||||
'balance_date' => '',
|
||||
'dates' => [],
|
||||
'alerts' => [],
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->syncForDate($companyId, $latest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{balance_date:string,dates:list<string>,alerts:array<int,array<string,mixed>>,created:int,updated:int}
|
||||
*/
|
||||
public function syncForDate(int $companyId, string $balanceDate): array
|
||||
{
|
||||
$dates = self::datesEndingAt($balanceDate);
|
||||
$stores = StoreReceivingAccount::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($stores->isEmpty()) {
|
||||
return [
|
||||
'balance_date' => $balanceDate,
|
||||
'dates' => $dates,
|
||||
'alerts' => [],
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$balancesByDate = $this->shopBalances->matchesForStoresOnDates($stores, $dates);
|
||||
$reserveStandards = $stores->mapWithKeys(
|
||||
static fn (StoreReceivingAccount $store): array => [
|
||||
(int) $store->id => WithdrawalReserveStandard::amountFor(
|
||||
$store->store_platform,
|
||||
$store->erp_store_name,
|
||||
),
|
||||
],
|
||||
)->all();
|
||||
$alerts = self::detect($stores, $balancesByDate, $reserveStandards, $dates);
|
||||
[$created, $updated] = DB::transaction(function () use ($companyId, $stores, &$alerts): array {
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
|
||||
foreach ($stores as $store) {
|
||||
$alert = $alerts[(int) $store->id] ?? null;
|
||||
if ($alert === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = $this->todoTitle($store);
|
||||
$description = $this->description($store, $alert);
|
||||
$todo = Todo::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->where('category', self::CATEGORY)
|
||||
->where('title', $title)
|
||||
->whereNull('deleted_at')
|
||||
->whereNotIn('status', ['已完成', '已关闭'])
|
||||
->orderByDesc('id')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($todo === null) {
|
||||
$todo = Todo::withoutGlobalScopes()->create([
|
||||
'company_id' => $companyId,
|
||||
'title' => $title,
|
||||
'category' => self::CATEGORY,
|
||||
'priority' => '高',
|
||||
'status' => self::TODO_STATUS,
|
||||
'description' => $description,
|
||||
]);
|
||||
$created++;
|
||||
} else {
|
||||
$attributes = [
|
||||
'priority' => '高',
|
||||
'description' => $description,
|
||||
];
|
||||
if ($todo->status !== self::TODO_STATUS) {
|
||||
$attributes['status'] = self::TODO_STATUS;
|
||||
}
|
||||
if ($todo->fill($attributes)->isDirty()) {
|
||||
$todo->save();
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
$alerts[(int) $store->id]['todo_id'] = (int) $todo->id;
|
||||
}
|
||||
|
||||
return [$created, $updated];
|
||||
});
|
||||
|
||||
return [
|
||||
'balance_date' => $balanceDate,
|
||||
'dates' => $dates,
|
||||
'alerts' => $alerts,
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<StoreReceivingAccount> $stores
|
||||
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>> $balancesByDate
|
||||
* @param array<int,int|null> $reserveStandards
|
||||
* @param list<string> $dates
|
||||
* @return array<int,array{balance:float,start_date:string,end_date:string,message:string}>
|
||||
*/
|
||||
public static function detect(
|
||||
iterable $stores,
|
||||
array $balancesByDate,
|
||||
array $reserveStandards,
|
||||
array $dates,
|
||||
): array {
|
||||
if (count($dates) !== 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$alerts = [];
|
||||
foreach ($stores as $store) {
|
||||
$reserve = $reserveStandards[(int) $store->id] ?? null;
|
||||
if ($reserve === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$balanceCents = null;
|
||||
$unchanged = true;
|
||||
foreach ($dates as $date) {
|
||||
$match = $balancesByDate[(int) $store->id][$date]
|
||||
?? ['status' => 'not_ready', 'balance' => null];
|
||||
if (($match['status'] ?? '') !== 'matched' || ($match['balance'] ?? null) === null) {
|
||||
$unchanged = false;
|
||||
break;
|
||||
}
|
||||
|
||||
$cents = (int) round((float) $match['balance'] * 100);
|
||||
if ($balanceCents === null) {
|
||||
$balanceCents = $cents;
|
||||
} elseif ($balanceCents !== $cents) {
|
||||
$unchanged = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $unchanged || $balanceCents === null || $balanceCents <= $reserve * 100) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$balance = $balanceCents / 100;
|
||||
$alerts[(int) $store->id] = [
|
||||
'balance' => $balance,
|
||||
'start_date' => $dates[0],
|
||||
'end_date' => $dates[2],
|
||||
'message' => sprintf(
|
||||
'余额连续 3 天保持 %s 元,高于预留标准 %s 元。',
|
||||
number_format($balance, 2),
|
||||
number_format($reserve, 2),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function datesEndingAt(string $balanceDate): array
|
||||
{
|
||||
$end = CarbonImmutable::createFromFormat('!Y-m-d', $balanceDate);
|
||||
if (! $end instanceof CarbonImmutable || $end->format('Y-m-d') !== $balanceDate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
$end->subDays(2)->toDateString(),
|
||||
$end->subDay()->toDateString(),
|
||||
$end->toDateString(),
|
||||
];
|
||||
}
|
||||
|
||||
private function todoTitle(StoreReceivingAccount $store): string
|
||||
{
|
||||
return mb_substr(
|
||||
'提现余额连续3天未变动|' . (string) $store->store_platform . '|' . (string) $store->erp_store_name,
|
||||
0,
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array{balance:float,start_date:string,end_date:string,message:string} $alert */
|
||||
private function description(StoreReceivingAccount $store, array $alert): string
|
||||
{
|
||||
return implode("\n", array_filter([
|
||||
$alert['message'],
|
||||
'统计期间:' . $alert['start_date'] . ' 至 ' . $alert['end_date'],
|
||||
'店铺平台:' . ($store->store_platform ?: '未配置'),
|
||||
'ERP店铺名称:' . ($store->erp_store_name ?: '未配置'),
|
||||
'店铺主体:' . ($store->store_entity_name ?: '未配置'),
|
||||
'平台店铺ID:' . ($store->store_id ?: '未配置'),
|
||||
'处理建议:核实店铺余额是否真实停滞,并检查是否应执行提现。',
|
||||
], static fn (string $line): bool => $line !== ''));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -17,7 +18,10 @@ class WithdrawalManagementReport
|
||||
'withdrawal_path',
|
||||
'withdrawal_account',
|
||||
'bank_card',
|
||||
'balance_two_days_ago',
|
||||
'balance_yesterday',
|
||||
'balance',
|
||||
'balance_alert',
|
||||
'reserve_standard',
|
||||
'withdrawal_amount',
|
||||
'compliance',
|
||||
@ -30,12 +34,15 @@ 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}>,
|
||||
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>,
|
||||
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>>,
|
||||
* balanceDays:list<array{key:string,date:string,label:string}>,
|
||||
* alerts:array<int,array{balance:float,start_date:string,end_date:string,message: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}
|
||||
* summary:array{source:int,matched:int,finance:int,ambiguous:int,unmatched:int,not_ready:int},
|
||||
* balanceColumnLabel:string,balanceDate:string,latestBalanceDate:?string,balanceDates:list<string>,sort:array{field:string,direction:string}
|
||||
* }
|
||||
*/
|
||||
public function results(Request $request): array
|
||||
@ -61,12 +68,33 @@ class WithdrawalManagementReport
|
||||
}
|
||||
|
||||
$today = now()->startOfDay();
|
||||
$balanceDate = $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);
|
||||
$historyDates = WithdrawalBalanceAlertSynchronizer::datesEndingAt($balanceDate);
|
||||
$balancesByDate = $this->shopBalances->matchesForStoresOnDates($rows, $historyDates);
|
||||
$balances = array_map(
|
||||
static fn (array $dates): array => $dates[$balanceDate] ?? [
|
||||
'status' => 'not_ready',
|
||||
'balance' => null,
|
||||
'source_store_name' => null,
|
||||
'source_platform' => null,
|
||||
],
|
||||
$balancesByDate,
|
||||
);
|
||||
$balanceCounts = collect($balances)->countBy(
|
||||
static fn (array $match): string => $match['status'],
|
||||
);
|
||||
$financeCount = collect($balances)->filter(
|
||||
static fn (array $match): bool => ($match['data_source'] ?? null)
|
||||
=== JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE,
|
||||
)->count();
|
||||
|
||||
if ($filters['balanceStatus'] !== '') {
|
||||
$rows = $rows->filter(
|
||||
@ -82,6 +110,19 @@ class WithdrawalManagementReport
|
||||
),
|
||||
],
|
||||
)->all();
|
||||
$balanceDays = collect($historyDates)->map(
|
||||
static fn (string $date, int $index): array => [
|
||||
'key' => $index === 0 ? 'balance_two_days_ago' : ($index === 1 ? 'balance_yesterday' : 'balance'),
|
||||
'date' => $date,
|
||||
'label' => CarbonImmutable::parse($date)->format('n月j日') . '店铺余额',
|
||||
],
|
||||
)->values()->all();
|
||||
$alerts = WithdrawalBalanceAlertSynchronizer::detect(
|
||||
$rows,
|
||||
$balancesByDate,
|
||||
$reserveStandards,
|
||||
$historyDates,
|
||||
);
|
||||
$withdrawalAmounts = $rows->mapWithKeys(
|
||||
fn (StoreReceivingAccount $row): array => [
|
||||
(int) $row->id => $this->withdrawalAmountFor($row, $balanceDate),
|
||||
@ -89,7 +130,7 @@ class WithdrawalManagementReport
|
||||
)->all();
|
||||
$compliance = $rows->mapWithKeys(
|
||||
fn (StoreReceivingAccount $row): array => [
|
||||
(int) $row->id => $this->complianceFor(
|
||||
(int) $row->id => self::complianceFor(
|
||||
$balances[(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null],
|
||||
$reserveStandards[(int) $row->id] ?? null,
|
||||
$withdrawalAmounts[(int) $row->id] ?? null,
|
||||
@ -98,7 +139,16 @@ class WithdrawalManagementReport
|
||||
)->all();
|
||||
$rows = $sort['field'] === ''
|
||||
? $this->sortByBalanceStatus($rows, $balances)
|
||||
: $this->sortRows($rows, $sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
: $this->sortRows(
|
||||
$rows,
|
||||
$sort,
|
||||
$balancesByDate,
|
||||
$balanceDays,
|
||||
$alerts,
|
||||
$reserveStandards,
|
||||
$withdrawalAmounts,
|
||||
$compliance,
|
||||
);
|
||||
|
||||
return [
|
||||
'filters' => $filters,
|
||||
@ -107,31 +157,43 @@ class WithdrawalManagementReport
|
||||
'storeEntities' => $storeEntities,
|
||||
'rows' => $rows,
|
||||
'balances' => $balances,
|
||||
'balancesByDate' => $balancesByDate,
|
||||
'balanceDays' => $balanceDays,
|
||||
'alerts' => $alerts,
|
||||
'reserveStandards' => $reserveStandards,
|
||||
'withdrawalAmounts' => $withdrawalAmounts,
|
||||
'compliance' => $compliance,
|
||||
'summary' => [
|
||||
'source' => $this->shopBalances->countForDate($balanceDate),
|
||||
'matched' => (int) $balanceCounts->get('matched', 0),
|
||||
'finance' => $financeCount,
|
||||
'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,
|
||||
'latestBalanceDate' => $latestBalanceDate,
|
||||
'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')),
|
||||
@ -139,6 +201,7 @@ class WithdrawalManagementReport
|
||||
'storeEntity' => $clean($request->query('store_entity'), 191),
|
||||
'keyword' => $clean($request->query('keyword'), 191),
|
||||
'balanceStatus' => $balanceStatus,
|
||||
'balanceDate' => $balanceDate,
|
||||
];
|
||||
}
|
||||
|
||||
@ -170,7 +233,7 @@ class WithdrawalManagementReport
|
||||
|
||||
/**
|
||||
* @param Collection<int,StoreReceivingAccount> $rows
|
||||
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
|
||||
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}> $balances
|
||||
* @return Collection<int,StoreReceivingAccount>
|
||||
*/
|
||||
private function sortByBalanceStatus(Collection $rows, array $balances): Collection
|
||||
@ -193,17 +256,35 @@ class WithdrawalManagementReport
|
||||
/**
|
||||
* @param Collection<int,StoreReceivingAccount> $rows
|
||||
* @param array{field:string,direction:string} $sort
|
||||
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
|
||||
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>> $balancesByDate
|
||||
* @param list<array{key:string,date:string,label:string}> $balanceDays
|
||||
* @param array<int,array{balance:float,start_date:string,end_date:string,message:string}> $alerts
|
||||
* @param array<int,int|null> $reserveStandards
|
||||
* @param array<int,float|null> $withdrawalAmounts
|
||||
* @param array<int,string|null> $compliance
|
||||
* @return Collection<int,StoreReceivingAccount>
|
||||
*/
|
||||
private function sortRows(Collection $rows, array $sort, array $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): Collection
|
||||
{
|
||||
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance): int {
|
||||
$leftValue = $this->sortValue($left, $sort['field'], $balances, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
$rightValue = $this->sortValue($right, $sort['field'], $balances, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
private function sortRows(
|
||||
Collection $rows,
|
||||
array $sort,
|
||||
array $balancesByDate,
|
||||
array $balanceDays,
|
||||
array $alerts,
|
||||
array $reserveStandards,
|
||||
array $withdrawalAmounts,
|
||||
array $compliance,
|
||||
): Collection {
|
||||
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use (
|
||||
$sort,
|
||||
$balancesByDate,
|
||||
$balanceDays,
|
||||
$alerts,
|
||||
$reserveStandards,
|
||||
$withdrawalAmounts,
|
||||
$compliance,
|
||||
): int {
|
||||
$leftValue = $this->sortValue($left, $sort['field'], $balancesByDate, $balanceDays, $alerts, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
$rightValue = $this->sortValue($right, $sort['field'], $balancesByDate, $balanceDays, $alerts, $reserveStandards, $withdrawalAmounts, $compliance);
|
||||
$comparison = $this->compareSortValues($leftValue, $rightValue, $sort['direction']);
|
||||
|
||||
return $comparison !== 0
|
||||
@ -213,13 +294,38 @@ class WithdrawalManagementReport
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
|
||||
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>> $balancesByDate
|
||||
* @param list<array{key:string,date:string,label:string}> $balanceDays
|
||||
* @param array<int,array{balance:float,start_date:string,end_date:string,message:string}> $alerts
|
||||
* @param array<int,int|null> $reserveStandards
|
||||
* @param array<int,float|null> $withdrawalAmounts
|
||||
* @param array<int,string|null> $compliance
|
||||
*/
|
||||
private function sortValue(StoreReceivingAccount $row, string $field, array $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): mixed
|
||||
{
|
||||
private function sortValue(
|
||||
StoreReceivingAccount $row,
|
||||
string $field,
|
||||
array $balancesByDate,
|
||||
array $balanceDays,
|
||||
array $alerts,
|
||||
array $reserveStandards,
|
||||
array $withdrawalAmounts,
|
||||
array $compliance,
|
||||
): mixed {
|
||||
$balanceFor = static function (string $dayKey) use ($row, $balancesByDate, $balanceDays): ?float {
|
||||
$date = null;
|
||||
foreach ($balanceDays as $day) {
|
||||
if ($day['key'] === $dayKey) {
|
||||
$date = $day['date'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$match = $date === null
|
||||
? null
|
||||
: ($balancesByDate[(int) $row->id][$date] ?? null);
|
||||
|
||||
return ($match['status'] ?? null) === 'matched' ? (float) $match['balance'] : null;
|
||||
};
|
||||
|
||||
return match ($field) {
|
||||
'platform' => $row->store_platform,
|
||||
'erp_store_name' => $row->erp_store_name,
|
||||
@ -228,9 +334,10 @@ class WithdrawalManagementReport
|
||||
'withdrawal_path' => $row->withdrawal_path,
|
||||
'withdrawal_account' => $row->withdrawal_account_name,
|
||||
'bank_card' => $row->bank_card_last_four,
|
||||
'balance' => ($balances[(int) $row->id]['status'] ?? null) === 'matched'
|
||||
? $balances[(int) $row->id]['balance']
|
||||
: null,
|
||||
'balance_two_days_ago' => $balanceFor('balance_two_days_ago'),
|
||||
'balance_yesterday' => $balanceFor('balance_yesterday'),
|
||||
'balance' => $balanceFor('balance'),
|
||||
'balance_alert' => isset($alerts[(int) $row->id]) ? 1 : 0,
|
||||
'reserve_standard' => $reserveStandards[(int) $row->id] ?? null,
|
||||
'withdrawal_amount' => $withdrawalAmounts[(int) $row->id] ?? null,
|
||||
'compliance' => $compliance[(int) $row->id] ?? null,
|
||||
@ -248,7 +355,7 @@ class WithdrawalManagementReport
|
||||
}
|
||||
|
||||
/** @param array{status:string,balance:?float} $balance */
|
||||
private function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
|
||||
public static function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
|
||||
{
|
||||
if (($balance['status'] ?? '') !== 'matched' || $balance['balance'] === null || $reserve === null || $withdrawalAmount === null) {
|
||||
return null;
|
||||
|
||||
@ -12,7 +12,9 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
@ -29,11 +31,30 @@ class WithdrawalManagementSpreadsheet
|
||||
];
|
||||
|
||||
private const CLEAR_VALUE = '[清空]';
|
||||
private const WITHDRAWAL_TEMPLATE_HEADERS = [
|
||||
'店铺平台',
|
||||
'ERP店铺名称',
|
||||
'平台店铺ID',
|
||||
'余额日期',
|
||||
'店铺余额',
|
||||
'预留标准',
|
||||
'提现金额',
|
||||
'提现后余额',
|
||||
'是否达标',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly JushuitanShopBalanceRepository $shopBalances,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* rows:\Illuminate\Support\Collection<int,StoreReceivingAccount>,
|
||||
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
|
||||
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>,
|
||||
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>>,
|
||||
* balanceDays:list<array{key:string,date:string,label:string}>,
|
||||
* alerts:array<int,array{balance:float,start_date:string,end_date:string,message:string}>,
|
||||
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
|
||||
* balanceColumnLabel:string,balanceDate:string
|
||||
* } $report
|
||||
@ -63,7 +84,7 @@ class WithdrawalManagementSpreadsheet
|
||||
['1. 店铺平台、ERP店铺名称用于定位店铺;有平台店铺ID时优先按平台和ID匹配。'],
|
||||
['2. 店铺主体、提现路径、提现账户名称、银行卡号后四位和提现金额为可更新字段。'],
|
||||
['3. 可更新字段留空表示保持原值,填写“' . self::CLEAR_VALUE . '”表示清空原值。'],
|
||||
['4. 提现金额须为非负数,最多两位小数,只用于导入当天对应的余额数据日期;上传后先预览再确认。'],
|
||||
['4. 提现金额须为非负数,最多两位小数,只用于当前页面显示的余额数据日期(最近 5 天内最新可用日期);上传后先预览再确认。'],
|
||||
], null, 'A1');
|
||||
$instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
||||
$instructions->getColumnDimension('A')->setWidth(88);
|
||||
@ -76,10 +97,113 @@ class WithdrawalManagementSpreadsheet
|
||||
return $this->download($spreadsheet, 'withdrawal_management_import_template.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前筛选预填店铺和余额参考值,同事只需填写提现金额。
|
||||
*
|
||||
* @param array<string,mixed> $report
|
||||
*/
|
||||
public function withdrawalTemplate(array $report): StreamedResponse
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('提现金额');
|
||||
$this->writeHeader($sheet, self::WITHDRAWAL_TEMPLATE_HEADERS);
|
||||
$this->sizeColumns($sheet, [14, 30, 20, 14, 18, 18, 18, 18, 14]);
|
||||
|
||||
$rowNumber = 2;
|
||||
foreach ($report['rows'] as $row) {
|
||||
$storeId = (int) $row->id;
|
||||
$balanceMatch = $report['balances'][$storeId] ?? [
|
||||
'status' => 'not_ready',
|
||||
'balance' => null,
|
||||
];
|
||||
$hasBalance = ($balanceMatch['status'] ?? '') === 'matched'
|
||||
&& ($balanceMatch['balance'] ?? null) !== null;
|
||||
$reserve = $report['reserveStandards'][$storeId] ?? null;
|
||||
$amount = $report['withdrawalAmounts'][$storeId] ?? null;
|
||||
|
||||
foreach ([
|
||||
'A' => (string) ($row->store_platform ?? ''),
|
||||
'B' => (string) ($row->erp_store_name ?? ''),
|
||||
'C' => (string) ($row->store_id ?? ''),
|
||||
'D' => (string) $report['balanceDate'],
|
||||
] as $column => $value) {
|
||||
$sheet->setCellValueExplicit("{$column}{$rowNumber}", $value, DataType::TYPE_STRING);
|
||||
}
|
||||
|
||||
if ($hasBalance) {
|
||||
$sheet->setCellValue("E{$rowNumber}", (float) $balanceMatch['balance']);
|
||||
} else {
|
||||
$sheet->setCellValueExplicit("E{$rowNumber}", $this->balanceStatus((string) ($balanceMatch['status'] ?? 'not_ready')), DataType::TYPE_STRING);
|
||||
}
|
||||
if ($reserve !== null) {
|
||||
$sheet->setCellValue("F{$rowNumber}", $reserve);
|
||||
} else {
|
||||
$sheet->setCellValueExplicit("F{$rowNumber}", '待配置', DataType::TYPE_STRING);
|
||||
}
|
||||
if ($amount !== null) {
|
||||
$sheet->setCellValue("G{$rowNumber}", $amount);
|
||||
}
|
||||
|
||||
$sheet->setCellValue(
|
||||
"H{$rowNumber}",
|
||||
"=IF(OR(NOT(ISNUMBER(E{$rowNumber})),G{$rowNumber}=\"\"),\"\",E{$rowNumber}-G{$rowNumber})",
|
||||
);
|
||||
$sheet->setCellValue(
|
||||
"I{$rowNumber}",
|
||||
"=IF(OR(H{$rowNumber}=\"\",NOT(ISNUMBER(F{$rowNumber}))),\"\",IF(H{$rowNumber}<=F{$rowNumber},\"是\",\"否\"))",
|
||||
);
|
||||
$rowNumber++;
|
||||
}
|
||||
|
||||
$lastDataRow = max(2, $rowNumber - 1);
|
||||
$sheet->getStyle("E2:I{$lastDataRow}")->getNumberFormat()->setFormatCode('#,##0.00');
|
||||
$sheet->getStyle("E2:I{$lastDataRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
$sheet->getStyle("G2:G{$lastDataRow}")->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()->setARGB('FFF7F0DE');
|
||||
$sheet->getStyle('G1')->getFill()
|
||||
->setFillType(Fill::FILL_SOLID)
|
||||
->getStartColor()->setARGB('FFB9954F');
|
||||
|
||||
$validation = new DataValidation();
|
||||
$validation->setType(DataValidation::TYPE_DECIMAL)
|
||||
->setOperator(DataValidation::OPERATOR_GREATERTHANOREQUAL)
|
||||
->setFormula1('0')
|
||||
->setAllowBlank(true)
|
||||
->setShowErrorMessage(true)
|
||||
->setErrorTitle('提现金额格式错误')
|
||||
->setError('请填写非负金额,最多两位小数。');
|
||||
$sheet->setDataValidation("G2:G{$lastDataRow}", $validation);
|
||||
|
||||
$instructions = $spreadsheet->createSheet();
|
||||
$instructions->setTitle('填写说明');
|
||||
$instructions->fromArray([
|
||||
['提现金额导入说明'],
|
||||
['1. 本模板按下载时的页面筛选生成,已预填店铺标识、余额日期、店铺余额和预留标准。'],
|
||||
['2. 只需填写黄色“提现金额”列;未填写金额的店铺不会提交。填写“' . self::CLEAR_VALUE . '”可清空该店铺提现金额。'],
|
||||
['3. 店铺余额、预留标准、提现后余额和是否达标仅用于下载时预览,系统会在上传后按最新余额日期重新计算。'],
|
||||
['4. 判定规则:店铺余额 - 提现金额 ≤ 预留标准时,“是否达标”为“是”,即该笔提现按大笔提现处理。'],
|
||||
['5. 模板余额日期为 ' . $report['balanceDate'] . ';日期滚动后请重新下载模板,避免上传过期金额。'],
|
||||
], null, 'A1');
|
||||
$instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
||||
$instructions->getColumnDimension('A')->setWidth(96);
|
||||
$instructions->getStyle('A1:A6')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
|
||||
foreach (range(1, 6) as $row) {
|
||||
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 26 : 36);
|
||||
}
|
||||
$spreadsheet->setActiveSheetIndex(0);
|
||||
|
||||
return $this->download(
|
||||
$spreadsheet,
|
||||
'withdrawal_amount_import_' . str_replace('-', '', (string) $report['balanceDate']) . '.xlsx',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* total:int,changed:int,unchanged:int,
|
||||
* preview:list<array{platform:string,erp_store_name:string,store_id:string,changes:string}>,
|
||||
* preview:list<array{platform:string,erp_store_name:string,store_id:string,changes:string,withdrawal_amount:?float,post_withdrawal_balance:?float,reserve_standard:?int,compliance:?string}>,
|
||||
* plan:list<array{id:int,updated_at:string,changes:array<string,string|null>}>
|
||||
* }
|
||||
*/
|
||||
@ -95,14 +219,16 @@ class WithdrawalManagementSpreadsheet
|
||||
'import' => 'Excel 文件无法读取,请确认文件未损坏并使用最新模板。',
|
||||
]);
|
||||
}
|
||||
[$headerIndex, $headers] = $this->headers($matrix);
|
||||
[$headerIndex, $headers, $headerMode] = $this->headers($matrix);
|
||||
$indexes = array_flip($headers);
|
||||
$balanceDate = now()->subDay()->toDateString();
|
||||
$balanceDate = $this->currentBalanceDate();
|
||||
$accounts = StoreReceivingAccount::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
[$byId, $byName] = $this->indexes($accounts);
|
||||
$balances = $this->shopBalances->matchesForStores($accounts, $balanceDate);
|
||||
$withdrawalOnlyMode = $headerMode === 'withdrawal';
|
||||
$errors = [];
|
||||
$plan = [];
|
||||
$preview = [];
|
||||
@ -112,12 +238,15 @@ class WithdrawalManagementSpreadsheet
|
||||
|
||||
foreach (array_slice($matrix, $headerIndex + 1, null, true) as $matrixIndex => $row) {
|
||||
$values = [];
|
||||
foreach (self::CONFIG_HEADERS as $header) {
|
||||
$values[$header] = trim((string) ($row[$indexes[$header]] ?? ''));
|
||||
foreach ([...self::CONFIG_HEADERS, '提现金额', '余额日期'] as $header) {
|
||||
$values[$header] = isset($indexes[$header])
|
||||
? trim((string) ($row[$indexes[$header]] ?? ''))
|
||||
: '';
|
||||
}
|
||||
// 专用模板会预填所有店铺,只把填写了提现金额的行视为提交数据。
|
||||
if ($withdrawalOnlyMode && $values['提现金额'] === '') {
|
||||
continue;
|
||||
}
|
||||
$values['提现金额'] = isset($indexes['提现金额'])
|
||||
? trim((string) ($row[$indexes['提现金额']] ?? ''))
|
||||
: '';
|
||||
if (count(array_filter($values, static fn (string $value): bool => $value !== '')) === 0) {
|
||||
continue;
|
||||
}
|
||||
@ -131,8 +260,13 @@ class WithdrawalManagementSpreadsheet
|
||||
$erpStoreName = mb_substr($values['ERP店铺名称'], 0, 191);
|
||||
$storeId = mb_substr(ltrim($values['平台店铺ID'], "'"), 0, 191);
|
||||
|
||||
if ($platform === '' || $erpStoreName === '') {
|
||||
$errors[] = "第 {$line} 行缺少店铺平台或 ERP 店铺名称";
|
||||
if ($platform === '' || ($storeId === '' && $erpStoreName === '')) {
|
||||
$errors[] = "第 {$line} 行缺少店铺平台或店铺标识";
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($withdrawalOnlyMode && $values['余额日期'] !== '' && $values['余额日期'] !== $balanceDate) {
|
||||
$errors[] = "第 {$line} 行余额日期 {$values['余额日期']} 已过期,请按最新余额日期 {$balanceDate} 重新下载模板";
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -187,11 +321,40 @@ class WithdrawalManagementSpreadsheet
|
||||
'changes' => $changes,
|
||||
];
|
||||
if (count($preview) < 20) {
|
||||
$amount = array_key_exists('withdrawal_amount', $changes)
|
||||
? ($changes['withdrawal_amount'] === null ? null : (float) $changes['withdrawal_amount'])
|
||||
: (
|
||||
$account->withdrawal_balance_date === $balanceDate
|
||||
&& $account->withdrawal_amount !== null
|
||||
&& is_numeric($account->withdrawal_amount)
|
||||
? (float) $account->withdrawal_amount
|
||||
: null
|
||||
);
|
||||
$balance = ($balances[(int) $account->id]['status'] ?? '') === 'matched'
|
||||
? (float) $balances[(int) $account->id]['balance']
|
||||
: null;
|
||||
$reserve = WithdrawalReserveStandard::amountFor(
|
||||
$account->store_platform,
|
||||
$account->erp_store_name,
|
||||
);
|
||||
$preview[] = [
|
||||
'platform' => $platform,
|
||||
'erp_store_name' => $erpStoreName,
|
||||
'store_id' => $storeId,
|
||||
'changes' => implode('、', $changedLabels),
|
||||
'withdrawal_amount' => $amount,
|
||||
'post_withdrawal_balance' => $balance !== null && $amount !== null
|
||||
? round($balance - $amount, 2)
|
||||
: null,
|
||||
'reserve_standard' => $reserve,
|
||||
'compliance' => WithdrawalManagementReport::complianceFor(
|
||||
[
|
||||
'status' => $balance === null ? 'not_ready' : 'matched',
|
||||
'balance' => $balance,
|
||||
],
|
||||
$reserve,
|
||||
$amount,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -221,6 +384,7 @@ class WithdrawalManagementSpreadsheet
|
||||
{
|
||||
return DB::transaction(function () use ($plan, $companyId): int {
|
||||
$updated = 0;
|
||||
$currentBalanceDate = null;
|
||||
|
||||
foreach ($plan as $item) {
|
||||
/** @var StoreReceivingAccount|null $account */
|
||||
@ -238,11 +402,13 @@ class WithdrawalManagementSpreadsheet
|
||||
}
|
||||
|
||||
if (isset($item['changes']['withdrawal_balance_date'])
|
||||
&& $item['changes']['withdrawal_balance_date'] !== null
|
||||
&& $item['changes']['withdrawal_balance_date'] !== now()->subDay()->toDateString()) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '余额日期已滚动,请重新上传并预览提现金额。',
|
||||
]);
|
||||
&& $item['changes']['withdrawal_balance_date'] !== null) {
|
||||
$currentBalanceDate ??= $this->currentBalanceDate();
|
||||
if ($item['changes']['withdrawal_balance_date'] !== $currentBalanceDate) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '余额日期已滚动,请重新上传并预览提现金额。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($item['changes'] as $field => $value) {
|
||||
@ -262,19 +428,36 @@ class WithdrawalManagementSpreadsheet
|
||||
|
||||
private function workbook(array $report): Spreadsheet
|
||||
{
|
||||
$headers = [...self::CONFIG_HEADERS, $report['balanceColumnLabel'], '预留标准', '提现金额', '是否达标'];
|
||||
$balanceHeaders = array_map(
|
||||
static fn (array $day): string => (string) $day['label'],
|
||||
$report['balanceDays'],
|
||||
);
|
||||
$headers = [
|
||||
...self::CONFIG_HEADERS,
|
||||
...$balanceHeaders,
|
||||
'余额来源',
|
||||
'余额预警',
|
||||
'预留标准',
|
||||
'提现金额',
|
||||
'是否达标',
|
||||
];
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('提现管理');
|
||||
$this->writeHeader($sheet, $headers);
|
||||
$this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 18, 18, 14]);
|
||||
$this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 20, 20, 32, 18, 18, 18, 14]);
|
||||
|
||||
$rowNumber = 2;
|
||||
foreach ($report['rows'] as $row) {
|
||||
$match = $report['balances'][(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null];
|
||||
$reserve = $report['reserveStandards'][(int) $row->id] ?? null;
|
||||
$withdrawalAmount = $report['withdrawalAmounts'][(int) $row->id] ?? null;
|
||||
$compliance = $report['compliance'][(int) $row->id] ?? null;
|
||||
$alert = $report['alerts'][(int) $row->id] ?? null;
|
||||
$hasCompleteAlertSeries = collect($report['balanceDays'])->every(
|
||||
fn (array $day): bool => (
|
||||
$report['balancesByDate'][(int) $row->id][(string) $day['date']]['status'] ?? ''
|
||||
) === 'matched',
|
||||
);
|
||||
$textValues = [
|
||||
1 => (string) ($row->store_platform ?? ''),
|
||||
2 => (string) ($row->erp_store_name ?? ''),
|
||||
@ -288,22 +471,46 @@ class WithdrawalManagementSpreadsheet
|
||||
$sheet->setCellValueExplicit([$column, $rowNumber], $value, DataType::TYPE_STRING);
|
||||
}
|
||||
|
||||
if ($match['status'] === 'matched') {
|
||||
$sheet->setCellValue([8, $rowNumber], (float) $match['balance']);
|
||||
} else {
|
||||
$sheet->setCellValueExplicit([8, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING);
|
||||
foreach ($report['balanceDays'] as $dayIndex => $day) {
|
||||
$column = 8 + $dayIndex;
|
||||
$match = $report['balancesByDate'][(int) $row->id][$day['date']]
|
||||
?? ['status' => 'not_ready', 'balance' => null];
|
||||
if ($match['status'] === 'matched') {
|
||||
$sheet->setCellValue([$column, $rowNumber], (float) $match['balance']);
|
||||
} else {
|
||||
$sheet->setCellValueExplicit([$column, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING);
|
||||
}
|
||||
}
|
||||
|
||||
$sourceParts = [];
|
||||
foreach ($report['balanceDays'] as $day) {
|
||||
$match = $report['balancesByDate'][(int) $row->id][$day['date']]
|
||||
?? ['status' => 'not_ready'];
|
||||
$sourceParts[] = str_replace('店铺余额', '', (string) $day['label']) . ':' . match ($match['status']) {
|
||||
'matched' => ($match['data_source'] ?? null) === 'finance' ? '财务系统' : '聚水潭',
|
||||
'ambiguous' => '多条匹配',
|
||||
'unmatched' => '未匹配',
|
||||
default => '待同步',
|
||||
};
|
||||
}
|
||||
$sheet->setCellValueExplicit([11, $rowNumber], implode(';', $sourceParts), DataType::TYPE_STRING);
|
||||
|
||||
$sheet->setCellValueExplicit(
|
||||
[12, $rowNumber],
|
||||
$alert !== null ? '连续3天未变动' : ($hasCompleteAlertSeries ? '未触发' : '数据不足'),
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
if ($reserve === null) {
|
||||
$sheet->setCellValueExplicit([9, $rowNumber], '待配置', DataType::TYPE_STRING);
|
||||
$sheet->setCellValueExplicit([13, $rowNumber], '待配置', DataType::TYPE_STRING);
|
||||
} else {
|
||||
$sheet->setCellValue([9, $rowNumber], $reserve);
|
||||
$sheet->setCellValue([13, $rowNumber], $reserve);
|
||||
}
|
||||
if ($withdrawalAmount === null) {
|
||||
$sheet->setCellValueExplicit([10, $rowNumber], '待接入', DataType::TYPE_STRING);
|
||||
$sheet->setCellValueExplicit([14, $rowNumber], '待接入', DataType::TYPE_STRING);
|
||||
} else {
|
||||
$sheet->setCellValue([10, $rowNumber], $withdrawalAmount);
|
||||
$sheet->setCellValue([14, $rowNumber], $withdrawalAmount);
|
||||
}
|
||||
$sheet->setCellValueExplicit([11, $rowNumber], match ($compliance) {
|
||||
$sheet->setCellValueExplicit([15, $rowNumber], match ($compliance) {
|
||||
'yes' => '是',
|
||||
'no' => '否',
|
||||
default => '待核验',
|
||||
@ -313,7 +520,9 @@ class WithdrawalManagementSpreadsheet
|
||||
|
||||
if ($rowNumber > 2) {
|
||||
$sheet->getStyle('H2:J' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00');
|
||||
$sheet->getStyle('M2:N' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00');
|
||||
$sheet->getStyle('H2:J' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
$sheet->getStyle('M2:N' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
||||
}
|
||||
$sheet->getStyle('C2:C' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@');
|
||||
$sheet->getStyle('G2:G' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@');
|
||||
@ -322,25 +531,47 @@ class WithdrawalManagementSpreadsheet
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:list<string>} */
|
||||
/** @return array{0:int,1:list<string>,2:'config'|'withdrawal'} */
|
||||
private function headers(array $matrix): array
|
||||
{
|
||||
foreach (array_slice($matrix, 0, 10, true) as $index => $row) {
|
||||
$headers = array_map(static fn (mixed $value): string => trim((string) $value), $row);
|
||||
if (in_array('店铺平台', $headers, true) && in_array('ERP店铺名称', $headers, true)) {
|
||||
if (! in_array('店铺平台', $headers, true) || ! in_array('提现金额', $headers, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->requireUniqueHeader($headers, '店铺平台');
|
||||
$this->requireUniqueHeader($headers, '提现金额');
|
||||
$fullConfigurationTemplate = collect(self::CONFIG_HEADERS)->every(
|
||||
static fn (string $header): bool => in_array($header, $headers, true),
|
||||
);
|
||||
|
||||
if ($fullConfigurationTemplate) {
|
||||
foreach (self::CONFIG_HEADERS as $required) {
|
||||
if (count(array_keys($headers, $required, true)) !== 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => "导入表头必须且只能包含一个“{$required}”列,请下载最新模板。",
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (count(array_keys($headers, '提现金额', true)) > 1) {
|
||||
throw ValidationException::withMessages(['import' => '导入表头只能包含一个“提现金额”列。']);
|
||||
$this->requireUniqueHeader($headers, $required);
|
||||
}
|
||||
|
||||
return [(int) $index, $headers];
|
||||
return [(int) $index, $headers, 'config'];
|
||||
}
|
||||
|
||||
if (! in_array('ERP店铺名称', $headers, true) && ! in_array('平台店铺ID', $headers, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => '提现金额模板必须包含“ERP店铺名称”或“平台店铺ID”用于定位店铺,请下载最新模板。',
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'ERP店铺名称', '平台店铺ID', '余额日期', '店铺余额', '预留标准',
|
||||
'提现后余额', '是否达标',
|
||||
] as $optional) {
|
||||
if (count(array_keys($headers, $optional, true)) > 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => "导入表头只能包含一个“{$optional}”列。",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [(int) $index, $headers, 'withdrawal'];
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
@ -348,6 +579,16 @@ class WithdrawalManagementSpreadsheet
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param list<string> $headers */
|
||||
private function requireUniqueHeader(array $headers, string $header): void
|
||||
{
|
||||
if (count(array_keys($headers, $header, true)) !== 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'import' => "导入表头必须且只能包含一个“{$header}”列,请下载最新模板。",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0:array<string,list<StoreReceivingAccount>>,1:array<string,list<StoreReceivingAccount>>} */
|
||||
private function indexes($accounts): array
|
||||
{
|
||||
@ -423,6 +664,14 @@ class WithdrawalManagementSpreadsheet
|
||||
return $changes;
|
||||
}
|
||||
|
||||
private function currentBalanceDate(): string
|
||||
{
|
||||
$today = now()->startOfDay();
|
||||
|
||||
return $this->shopBalances->latestBalanceDate($today)
|
||||
?? $today->copy()->subDay()->toDateString();
|
||||
}
|
||||
|
||||
private function key(string $platform, string $identity): string
|
||||
{
|
||||
return trim($platform) . "\0" . trim($identity);
|
||||
|
||||
@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\ProductMonthlyWeightedAverageReporter;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 检查上游完整性后,自动生成尚未计算的最新月份商品月加权平均单价。
|
||||
*/
|
||||
class AutoComputeProductMonthlyWeightedAverage extends Command
|
||||
{
|
||||
protected $signature = 'product:monthly-weighted-average:auto
|
||||
{--month= : 指定月份;默认取最新已成功的进销存月份}
|
||||
{--result-json= : 将执行结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '上游完整后自动计算商品月加权平均单价,并保护已计算历史月份';
|
||||
|
||||
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
|
||||
{
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$month = $this->targetMonth($connection);
|
||||
if ($month === null) {
|
||||
return $this->finish('blocked', null, [
|
||||
'message' => '未找到已成功导入的进销存月份。',
|
||||
]);
|
||||
}
|
||||
|
||||
$lock = Cache::lock('product-monthly-weighted-average-auto:' . $month->format('Y-m'), 7200);
|
||||
if (! $lock->get()) {
|
||||
return $this->finish('blocked', $month, [
|
||||
'message' => '同月自动计算任务正在执行。',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$state = DB::connection($connection)->table('product_monthly_weighted_average_states')
|
||||
->where('report_month', $month->toDateString())
|
||||
->first();
|
||||
if (($state?->status ?? null) === 'computed') {
|
||||
$report = $reporter->build($connection, $month);
|
||||
$report['calculation_status'] = $report['status'];
|
||||
$report['status'] = 'already_computed';
|
||||
|
||||
return $this->finish('already_computed', $month, $report, $reporter);
|
||||
}
|
||||
|
||||
$checks = $this->checks($connection, $month);
|
||||
if (in_array(false, array_column($checks, 'passed'), true)) {
|
||||
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
|
||||
['report_month' => $month->toDateString()],
|
||||
[
|
||||
'status' => 'blocked',
|
||||
'last_error' => '上游数据未通过完整性检查',
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
return $this->finish('blocked', $month, [
|
||||
'message' => '上游数据未更新或不完整,未执行计算。',
|
||||
'checks' => $checks,
|
||||
]);
|
||||
}
|
||||
|
||||
$baseExit = $this->call('product:monthly-weighted-average:base', [
|
||||
'month' => $month->format('Y-m'),
|
||||
'--from-previous' => true,
|
||||
// 自动任务只会在尚未 computed 的月份走到这里,可安全清理失败重试留下
|
||||
// 的同月基表;已 computed 的历史月份在前面的门禁直接跳过。
|
||||
'--force' => true,
|
||||
]);
|
||||
if ($baseExit !== self::SUCCESS) {
|
||||
return $this->finish('failed', $month, [
|
||||
'message' => '生成期初基数失败。',
|
||||
]);
|
||||
}
|
||||
|
||||
$computeExit = $this->call('product:monthly-weighted-average:compute', [
|
||||
'month' => $month->format('Y-m'),
|
||||
]);
|
||||
if ($computeExit !== self::SUCCESS) {
|
||||
return $this->finish('failed', $month, [
|
||||
'message' => '加权平均单价计算失败。',
|
||||
]);
|
||||
}
|
||||
|
||||
$report = $reporter->build($connection, $month);
|
||||
$report['checks'] = $checks;
|
||||
|
||||
return $this->finish(
|
||||
($report['status'] ?? null) === 'review_required' ? 'review_required' : 'success',
|
||||
$month,
|
||||
$report,
|
||||
$reporter,
|
||||
);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function targetMonth(string $connection): ?CarbonImmutable
|
||||
{
|
||||
$requested = trim((string) $this->option('month'));
|
||||
if ($requested !== '') {
|
||||
return CarbonImmutable::parse($requested)->startOfMonth();
|
||||
}
|
||||
|
||||
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('status', 'succeeded')
|
||||
->whereNotNull('active_generation')
|
||||
->orderByDesc('source_month')
|
||||
->value('source_month');
|
||||
|
||||
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
|
||||
}
|
||||
|
||||
/** @return list<array{name:string,passed:bool,actual:mixed,expected:mixed}> */
|
||||
private function checks(string $connection, CarbonImmutable $month): array
|
||||
{
|
||||
$db = DB::connection($connection);
|
||||
$monthState = $db->table('inventory_report_authoritative_months')
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('source_month', $month->toDateString())
|
||||
->first();
|
||||
$generation = $monthState?->active_generation;
|
||||
$activeRows = $generation === null
|
||||
? 0
|
||||
: $db->table('inventory_report_authoritative_rows')
|
||||
->where('generation', (string) $generation)
|
||||
->count();
|
||||
$expectedRows = (int) ($monthState?->source_row_count ?? 0);
|
||||
|
||||
$previous = $month->subMonthNoOverflow();
|
||||
$previousState = $db->table('product_monthly_weighted_average_states')
|
||||
->where('report_month', $previous->toDateString())
|
||||
->value('status');
|
||||
$previousRows = $db->table('product_monthly_weighted_average_rows')
|
||||
->where('report_month', $previous->toDateString())
|
||||
->count();
|
||||
|
||||
$inboundRows = $db->table('inventory_inbound_detail_rows')
|
||||
->where('report_month', $month->toDateString())
|
||||
->count();
|
||||
$inboundMissingDimensions = $db->table('inventory_inbound_detail_rows')
|
||||
->where('report_month', $month->toDateString())
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('barcode')->orWhere('barcode', '')
|
||||
->orWhereNull('warehouse_short_name')->orWhere('warehouse_short_name', '');
|
||||
})
|
||||
->count();
|
||||
$inboundMissingAmounts = $db->table('inventory_inbound_detail_rows')
|
||||
->where('report_month', $month->toDateString())
|
||||
->whereNull('amount')
|
||||
->whereNull('estimated_amount')
|
||||
->count();
|
||||
$productCatalogRows = $db->table('ops_jst_sku')->whereNotNull('sku_id')->count();
|
||||
|
||||
return [
|
||||
[
|
||||
'name' => '进销存月份状态成功',
|
||||
'passed' => ($monthState?->status ?? null) === 'succeeded',
|
||||
'actual' => $monthState?->status,
|
||||
'expected' => 'succeeded',
|
||||
],
|
||||
[
|
||||
'name' => '进销存激活明细行数完整',
|
||||
'passed' => $expectedRows > 0 && $activeRows === $expectedRows,
|
||||
'actual' => $activeRows,
|
||||
'expected' => $expectedRows,
|
||||
],
|
||||
[
|
||||
'name' => '上月加权平均结果已完成',
|
||||
'passed' => $previousState === 'computed' && $previousRows > 0,
|
||||
'actual' => ['status' => $previousState, 'rows' => $previousRows],
|
||||
'expected' => ['status' => 'computed', 'rows' => '> 0'],
|
||||
],
|
||||
[
|
||||
'name' => '入库明细已更新',
|
||||
'passed' => $inboundRows > 0,
|
||||
'actual' => $inboundRows,
|
||||
'expected' => '> 0',
|
||||
],
|
||||
[
|
||||
'name' => '入库商品编码与仓库简称无缺失',
|
||||
'passed' => $inboundMissingDimensions === 0,
|
||||
'actual' => $inboundMissingDimensions,
|
||||
'expected' => 0,
|
||||
],
|
||||
[
|
||||
'name' => '入库金额至少取金额或预估金额',
|
||||
'passed' => $inboundMissingAmounts === 0,
|
||||
'actual' => $inboundMissingAmounts,
|
||||
'expected' => 0,
|
||||
],
|
||||
[
|
||||
'name' => '普通商品资料已更新',
|
||||
'passed' => $productCatalogRows > 0,
|
||||
'actual' => $productCatalogRows,
|
||||
'expected' => '> 0',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function finish(
|
||||
string $status,
|
||||
?CarbonImmutable $month,
|
||||
array $payload,
|
||||
?ProductMonthlyWeightedAverageReporter $reporter = null,
|
||||
): int {
|
||||
$result = ['status' => $status] + $payload;
|
||||
if (! isset($result['report_month']) && $month !== null) {
|
||||
$result['report_month'] = $month->toDateString();
|
||||
}
|
||||
|
||||
$path = trim((string) $this->option('result-json'));
|
||||
if ($path !== '') {
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
File::put(
|
||||
$path,
|
||||
json_encode(
|
||||
$result,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
|
||||
) . PHP_EOL,
|
||||
);
|
||||
}
|
||||
|
||||
if ($reporter !== null && isset($result['totals'])) {
|
||||
$this->line($reporter->brief($result));
|
||||
} elseif (isset($result['message'])) {
|
||||
$this->error((string) $result['message']);
|
||||
}
|
||||
|
||||
return in_array($status, ['blocked', 'failed'], true)
|
||||
? self::FAILURE
|
||||
: self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 固定月度顺序:先生成商品月加权平均单价,再同步商品成本表。
|
||||
*/
|
||||
class AutoMonthlyWeightedAverageAndProductCost extends Command
|
||||
{
|
||||
protected $signature = 'finance:monthly-weighted-product-cost:auto
|
||||
{--month= : 指定月份;默认取最新已成功的进销存月份}
|
||||
{--result-json= : 将两阶段结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '先计算商品月加权平均单价,再自动同步商品成本表';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$month = $this->targetMonth($connection);
|
||||
if ($month === null) {
|
||||
return $this->writeResult([
|
||||
'status' => 'blocked',
|
||||
'message' => '未找到已成功导入的进销存月份。',
|
||||
]);
|
||||
}
|
||||
|
||||
$weightedPath = tempnam(sys_get_temp_dir(), 'weighted-result-') . '.json';
|
||||
$costPath = tempnam(sys_get_temp_dir(), 'product-cost-result-') . '.json';
|
||||
try {
|
||||
$weightedExit = $this->call('product:monthly-weighted-average:auto', [
|
||||
'--month' => $month->format('Y-m'),
|
||||
'--result-json' => $weightedPath,
|
||||
]);
|
||||
$weighted = $this->readJson($weightedPath);
|
||||
if (
|
||||
$weightedExit !== self::SUCCESS
|
||||
|| in_array($weighted['status'] ?? 'failed', ['blocked', 'failed'], true)
|
||||
) {
|
||||
return $this->writeResult([
|
||||
'status' => 'failed',
|
||||
'report_month' => $month->toDateString(),
|
||||
'message' => '商品月加权平均单价未完成,商品成本未执行。',
|
||||
'weighted_average' => $weighted,
|
||||
'product_cost' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$costExit = $this->call('store-receipt:product-cost:sync', [
|
||||
'month' => $month->format('Y-m'),
|
||||
'--result-json' => $costPath,
|
||||
]);
|
||||
$cost = $this->readJson($costPath);
|
||||
$status = $costExit !== self::SUCCESS
|
||||
? 'failed'
|
||||
: ((int) ($cost['pending_cost_rows'] ?? 0) > 0 ? 'review_required' : 'success');
|
||||
|
||||
return $this->writeResult([
|
||||
'status' => $status,
|
||||
'report_month' => $month->toDateString(),
|
||||
'message' => match ($status) {
|
||||
'success' => '月加权平均单价与商品成本已按顺序完成。',
|
||||
'review_required' => '月加权平均单价与商品成本已完成,存在待核验成本。',
|
||||
default => '商品成本同步失败。',
|
||||
},
|
||||
'weighted_average' => $weighted,
|
||||
'product_cost' => $cost,
|
||||
]);
|
||||
} finally {
|
||||
@unlink($weightedPath);
|
||||
@unlink($costPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function targetMonth(string $connection): ?CarbonImmutable
|
||||
{
|
||||
$requested = trim((string) $this->option('month'));
|
||||
if ($requested !== '') {
|
||||
return CarbonImmutable::parse($requested)->startOfMonth();
|
||||
}
|
||||
|
||||
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
|
||||
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
|
||||
->where('status', 'succeeded')
|
||||
->whereNotNull('active_generation')
|
||||
->orderByDesc('source_month')
|
||||
->value('source_month');
|
||||
|
||||
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function readJson(string $path): array
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return ['status' => 'failed', 'message' => '子任务未生成结果文件。'];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
|
||||
return is_array($decoded)
|
||||
? $decoded
|
||||
: ['status' => 'failed', 'message' => '子任务结果文件无法解析。'];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $result */
|
||||
private function writeResult(array $result): int
|
||||
{
|
||||
$path = trim((string) $this->option('result-json'));
|
||||
if ($path !== '') {
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
File::put(
|
||||
$path,
|
||||
json_encode(
|
||||
$result,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
|
||||
) . PHP_EOL,
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($result['message'])) {
|
||||
$this->line((string) $result['message']);
|
||||
}
|
||||
|
||||
return in_array($result['status'] ?? 'failed', ['success', 'review_required'], true)
|
||||
? self::SUCCESS
|
||||
: self::FAILURE;
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,26 +2,45 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\ProductMonthlyWeightedAverageReporter;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 按取数逻辑计算某个月的更改价格、定价状态、采购单价、期初金额、入库金额和加权平均单价。
|
||||
*/
|
||||
class ComputeProductMonthlyWeightedAverage extends Command
|
||||
{
|
||||
protected $signature = 'product:monthly-weighted-average:compute {month : 报表月份,例如 2026-07}';
|
||||
protected $signature = 'product:monthly-weighted-average:compute
|
||||
{month : 报表月份,例如 2026-07}
|
||||
{--force : 覆盖已经计算完成的同月结果}
|
||||
{--result-json= : 将结果简报写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '按取数逻辑计算商品月加权平均单价';
|
||||
|
||||
public function handle(): int
|
||||
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
|
||||
{
|
||||
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$table = 'product_monthly_weighted_average_rows';
|
||||
$deadline = $month->addMonthNoOverflow()->day(2)->startOfDay();
|
||||
|
||||
$stateStatus = DB::connection($connection)
|
||||
->table('product_monthly_weighted_average_states')
|
||||
->where('report_month', $month->toDateString())
|
||||
->value('status');
|
||||
$computedRows = DB::connection($connection)->table($table)
|
||||
->where('report_month', $month->toDateString())
|
||||
->whereNotNull('computed_at')
|
||||
->count();
|
||||
if (! $this->option('force') && ($stateStatus === 'computed' || $computedRows > 0)) {
|
||||
$this->error($month->format('Y-m') . ' 已经计算完成,历史月份不自动覆盖;如需人工纠正请显式使用 --force。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$query = DB::connection($connection)->table($table)->where('report_month', $month->toDateString());
|
||||
$codes = (clone $query)->distinct()->pluck('product_code')->map(fn ($code) => trim((string) $code))->unique()->values()->all();
|
||||
$this->info('待计算 SKU:' . count($codes));
|
||||
@ -87,7 +106,21 @@ class ComputeProductMonthlyWeightedAverage extends Command
|
||||
['status' => 'computed', 'computed_rows' => $computed, 'last_computed_at' => $now, 'updated_at' => $now, 'created_at' => $now],
|
||||
);
|
||||
|
||||
$report = $reporter->build($connection, $month);
|
||||
$json = json_encode($report, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
if (is_string($json)) {
|
||||
$resultPath = trim((string) $this->option('result-json'));
|
||||
if ($resultPath !== '') {
|
||||
File::ensureDirectoryExists(dirname($resultPath));
|
||||
File::put($resultPath, $json . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info($month->format('Y-m') . " 计算完成:{$computed} 行");
|
||||
$this->line($reporter->brief($report));
|
||||
if (($report['status'] ?? null) === 'review_required') {
|
||||
$this->warn('存在待复核差异;结果已生成,但不会视为完全无异常。');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
32
app/Console/Commands/ImportAccessoryReshipCourierConfigs.php
Normal file
32
app/Console/Commands/ImportAccessoryReshipCourierConfigs.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Common\Company;
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\AccessoryReshipCourierImporter;
|
||||
use Modules\EcommerceFinance\Services\AccessoryReshipCourierStore;
|
||||
|
||||
class ImportAccessoryReshipCourierConfigs extends Command
|
||||
{
|
||||
protected $signature = 'accessory-reship:import-courier-configs {file} {--company= : 导入所属公司ID} {--dry-run : 仅检查文件及输出条数,不写入}';
|
||||
protected $description = '导入用户提供的存量快递补发扣款比例配置(重复项跳过、冲突整批回滚)';
|
||||
|
||||
public function handle(AccessoryReshipCourierImporter $importer, AccessoryReshipCourierStore $store): int
|
||||
{
|
||||
$companyId = (int) $this->option('company');
|
||||
if ($companyId <= 0 || !Company::query()->where('id', $companyId)->exists()) {
|
||||
$this->error('请指定有效的 --company,公司配置不会跨公司写入。');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$path = $this->argument('file');
|
||||
$rows = $importer->read($path);
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('文件校验通过,共' . count($rows) . '条;未写入。');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$result = $store->import($companyId, $rows, '用户授权存量配置导入', hash_file('sha256', $path));
|
||||
$this->info('导入完成:新增' . $result['added'] . '条,重复跳过' . $result['skipped'] . '条。');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
32
app/Console/Commands/ImportAccountMappingFiles.php
Normal file
32
app/Console/Commands/ImportAccountMappingFiles.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\AccountMappingStore;
|
||||
|
||||
/**
|
||||
* 把科目映射表的旧文件存储(storage/app/account-mapping/rules.json / events.json)
|
||||
* 导入共享系统数据库(ec_account_mapping_rules / ec_account_mapping_events)。
|
||||
*
|
||||
* 幂等:以文件为准覆盖数据库内容,可重复执行。
|
||||
*/
|
||||
class ImportAccountMappingFiles extends Command
|
||||
{
|
||||
protected $signature = 'account-mapping:import-files {--events : 一并导入操作记录}';
|
||||
|
||||
protected $description = '把科目映射表的旧文件存储导入共享数据库';
|
||||
|
||||
public function handle(AccountMappingStore $store): int
|
||||
{
|
||||
$result = $store->syncFromFiles((bool) $this->option('events'));
|
||||
|
||||
$this->info(
|
||||
'已导入规则 ' . $result['rules'] . ' 条'
|
||||
. ($result['events'] > 0 ? ',操作记录 ' . $result['events'] . ' 条' : '')
|
||||
. '。'
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,9 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business\InventoryWarehouseNameMapping;
|
||||
use Carbon\CarbonImmutable;
|
||||
use App\Business\InventoryInboundDetailImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 进销存报表 → 入库明细数据:按原始 Excel 原样导入,仓库简称用仓库名称映射表按「仓库」唯一匹配。
|
||||
@ -17,194 +15,28 @@ class ImportInventoryInboundDetail extends Command
|
||||
|
||||
protected $description = '导入进销存报表的入库明细数据';
|
||||
|
||||
public function handle(): int
|
||||
public function handle(InventoryInboundDetailImporter $importer): int
|
||||
{
|
||||
$file = (string) $this->argument('file');
|
||||
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
$table = 'inventory_inbound_detail_rows';
|
||||
$forcedMonth = trim((string) ($this->option('month') ?? ''));
|
||||
$now = now();
|
||||
|
||||
$maps = $this->shortNameMap();
|
||||
$this->info('仓库名称映射:仓库名 ' . count($maps['warehouse']) . ' 个、仓储方 ' . count($maps['provider']) . ' 个可唯一匹配');
|
||||
try {
|
||||
$result = $importer->import($file, $forcedMonth);
|
||||
$this->info(sprintf(
|
||||
'导入完成:%d 行,仓库简称匹配上 %d 行,跳过 %d 行;库中该表共 %d 行',
|
||||
$result['imported'],
|
||||
$result['mapped'],
|
||||
$result['skipped'],
|
||||
$result['total'],
|
||||
));
|
||||
|
||||
// 原样导入:以本次文件为准整体重建,不做去重、不做加工。
|
||||
DB::connection($connection)->table($table)->delete();
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('入库明细导入失败:' . $exception->getMessage());
|
||||
$this->warn('已保留上一版有效数据。');
|
||||
|
||||
$reader = IOFactory::createReaderForFile($file);
|
||||
$reader->setReadDataOnly(true);
|
||||
$sheet = $reader->load($file)->getSheet(0);
|
||||
|
||||
$batch = [];
|
||||
$imported = 0;
|
||||
$mapped = 0;
|
||||
$skipped = 0;
|
||||
foreach ($sheet->getRowIterator(2) as $row) {
|
||||
$rowNumber = $row->getRowIndex();
|
||||
$cells = [];
|
||||
foreach ($row->getCellIterator('A', 'U') as $cell) {
|
||||
$cells[] = $cell->getFormattedValue();
|
||||
}
|
||||
$warehouse = trim((string) ($cells[14] ?? ''));
|
||||
$createdRaw = trim((string) ($cells[16] ?? ''));
|
||||
if ($warehouse === '' && $createdRaw === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$month = $forcedMonth !== '' ? $forcedMonth : $this->monthOf($createdRaw);
|
||||
if ($month === '') {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 一般用"仓库"匹配仓库简称;含"代发仓"字样的按"仓储方"匹配。
|
||||
$shortName = '';
|
||||
if ($warehouse !== '') {
|
||||
$shortName = str_contains($warehouse, '代发仓')
|
||||
? ($maps['provider'][$warehouse] ?? $maps['warehouse'][$warehouse] ?? '')
|
||||
: ($maps['warehouse'][$warehouse] ?? '');
|
||||
}
|
||||
if ($shortName !== '') {
|
||||
$mapped++;
|
||||
}
|
||||
|
||||
$batch[] = [
|
||||
'report_month' => $month . '-01',
|
||||
// 唯一键按 Excel 行号生成,保留原表每一行(含重复行)。
|
||||
'row_key' => sha1($month . '|' . $rowNumber),
|
||||
'source_no' => $this->text($cells[0] ?? ''),
|
||||
'document_no' => $this->text($cells[1] ?? ''),
|
||||
'created_serial' => $this->text($cells[2] ?? ''),
|
||||
'supplier' => $this->text($cells[3] ?? ''),
|
||||
'item_no' => $this->text($cells[4] ?? ''),
|
||||
'barcode' => $this->text($cells[5] ?? ''),
|
||||
'spec_name' => $this->text($cells[6] ?? ''),
|
||||
'quantity' => $this->number($cells[7] ?? ''),
|
||||
'unit_price' => $this->number($cells[8] ?? ''),
|
||||
'amount' => $this->number($cells[9] ?? ''),
|
||||
'estimated_unit_price' => $this->number($cells[10] ?? ''),
|
||||
'estimated_amount' => $this->number($cells[11] ?? ''),
|
||||
'supplier_barcode' => $this->text($cells[12] ?? ''),
|
||||
'remark' => $this->text($cells[13] ?? ''),
|
||||
'warehouse' => $warehouse !== '' ? $warehouse : null,
|
||||
'warehouse_short_name' => $shortName !== '' ? $shortName : null,
|
||||
'created_at_raw' => $this->moment($createdRaw),
|
||||
'express_name' => $this->text($cells[17] ?? ''),
|
||||
'express_no' => $this->text($cells[18] ?? ''),
|
||||
'not_sent' => $this->text($cells[19] ?? ''),
|
||||
'owner_name' => $this->text($cells[20] ?? ''),
|
||||
'imported_at' => $now,
|
||||
];
|
||||
$imported++;
|
||||
|
||||
if (count($batch) >= 1000) {
|
||||
DB::connection($connection)->table($table)->insertOrIgnore($batch);
|
||||
$this->info('已写入 ' . $imported . ' 行');
|
||||
$batch = [];
|
||||
}
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($batch !== []) {
|
||||
DB::connection($connection)->table($table)->insertOrIgnore($batch);
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'导入完成:%d 行,仓库简称匹配上 %d 行,跳过 %d 行;库中该表共 %d 行',
|
||||
$imported,
|
||||
$mapped,
|
||||
$skipped,
|
||||
DB::connection($connection)->table($table)->count(),
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{warehouse:array<string,string>,provider:array<string,string>}
|
||||
* 仅保留能唯一匹配的键
|
||||
*/
|
||||
private function shortNameMap(): array
|
||||
{
|
||||
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
|
||||
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$warehouse = trim((string) $row->warehouse);
|
||||
if ($warehouse === '') {
|
||||
continue;
|
||||
}
|
||||
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
|
||||
$byWarehouse = [];
|
||||
foreach ($grouped as $warehouse => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byWarehouse[$warehouse] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
$byProvider = [];
|
||||
foreach ($rows as $row) {
|
||||
$provider = trim((string) $row->warehouse_provider);
|
||||
if ($provider === '') {
|
||||
continue;
|
||||
}
|
||||
$byProvider[$provider][] = trim((string) $row->warehouse_short_name);
|
||||
}
|
||||
foreach ($byProvider as $provider => $names) {
|
||||
$unique = array_values(array_unique($names));
|
||||
if (count($unique) === 1 && $unique[0] !== '') {
|
||||
$byProvider[$provider] = $unique[0];
|
||||
}
|
||||
}
|
||||
|
||||
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
|
||||
}
|
||||
|
||||
private function monthOf(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
|
||||
$parsed = \DateTimeImmutable::createFromFormat($format, $value);
|
||||
if ($parsed instanceof \DateTimeImmutable) {
|
||||
return $parsed->format('Y-m');
|
||||
}
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? '' : date('Y-m', $timestamp);
|
||||
}
|
||||
|
||||
private function moment(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$timestamp = strtotime($value);
|
||||
|
||||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
|
||||
private function text(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function number(mixed $value): ?float
|
||||
{
|
||||
$text = str_replace(',', '', trim((string) $value));
|
||||
if ($text === '' || ! is_numeric($text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $text;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\WithdrawalBalanceAlertSynchronizer;
|
||||
use App\Models\Business\StoreReceivingAccount;
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceBillShopBalanceFallback;
|
||||
use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter;
|
||||
use Throwable;
|
||||
|
||||
@ -14,9 +17,13 @@ class ImportJushuitanShopBalances extends Command
|
||||
{--scope=all : 来源范围,同一日期同一范围会被原子替换}
|
||||
{--dry-run : 只校验和统计,不写入共享业务库}';
|
||||
|
||||
protected $description = '校验并导入聚水潭平台原始账单中的店铺余额快照';
|
||||
protected $description = '导入聚水潭店铺余额,并用财务账单补录缺失店铺';
|
||||
|
||||
public function handle(JushuitanShopBalanceImporter $importer): int
|
||||
public function handle(
|
||||
JushuitanShopBalanceImporter $importer,
|
||||
WithdrawalBalanceAlertSynchronizer $alertSynchronizer,
|
||||
FinanceBillShopBalanceFallback $financeFallback,
|
||||
): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
@ -26,6 +33,7 @@ class ImportJushuitanShopBalances extends Command
|
||||
(bool) $this->option('dry-run'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
@ -39,6 +47,60 @@ class ImportJushuitanShopBalances extends Command
|
||||
$result['replaced'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
$financeFallbackFailed = false;
|
||||
try {
|
||||
$fallback = $financeFallback->fillMissing(
|
||||
$result['balance_date'],
|
||||
(string) $this->option('scope'),
|
||||
(bool) $result['dry_run'],
|
||||
);
|
||||
if (! $fallback['supported']) {
|
||||
$this->warn('账单余额回填:当前来源的店铺收款账户账单没有累计账户余额字段,跳过回填。');
|
||||
} elseif ($fallback['missing'] === 0) {
|
||||
$this->info('账单余额回填:聚水潭余额没有缺失店铺。');
|
||||
} else {
|
||||
$this->info(sprintf(
|
||||
'账单余额回填:缺失 %d 家,匹配 %d 家,写入 %d 条,仍有 %d 家待补。',
|
||||
$fallback['missing'],
|
||||
$fallback['resolved'],
|
||||
$fallback['filled'],
|
||||
$fallback['unresolved'],
|
||||
));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->error('财务系统店铺收款账户账单余额回填失败,聚水潭余额已保留,财务回填未覆盖。');
|
||||
$financeFallbackFailed = true;
|
||||
}
|
||||
|
||||
if (! $result['dry_run']) {
|
||||
$companyIds = StoreReceivingAccount::withoutGlobalScopes()
|
||||
->whereNull('deleted_at')
|
||||
->distinct()
|
||||
->pluck('company_id')
|
||||
->filter()
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
foreach ($companyIds as $companyId) {
|
||||
try {
|
||||
$alerts = $alertSynchronizer->syncLatest($companyId);
|
||||
if ($alerts['created'] > 0 || $alerts['updated'] > 0) {
|
||||
$this->info(sprintf(
|
||||
'余额预警待办:公司 %d 新增 %d 条、更新 %d 条。',
|
||||
$companyId,
|
||||
$alerts['created'],
|
||||
$alerts['updated'],
|
||||
));
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->warn("余额预警待办生成失败(公司 {$companyId}),余额快照已保留。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $financeFallbackFailed ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,8 @@ class ImportProductMonthlyWeightedAverageBase extends Command
|
||||
protected $signature = 'product:monthly-weighted-average:base
|
||||
{month : 报表月份,例如 2026-07}
|
||||
{--file= : 期初基数 Excel(表1),首月使用}
|
||||
{--from-previous : 用上月的加权平均单价作为本月原始价格}';
|
||||
{--from-previous : 用上月的加权平均单价作为本月原始价格}
|
||||
{--force : 覆盖已经存在的同月基数}';
|
||||
|
||||
protected $description = '导入/生成商品月加权平均单价的期初基数';
|
||||
|
||||
@ -32,6 +33,18 @@ class ImportProductMonthlyWeightedAverageBase extends Command
|
||||
$rowsTable = 'product_monthly_weighted_average_rows';
|
||||
$now = now();
|
||||
|
||||
$existingRows = DB::connection($connection)->table($rowsTable)
|
||||
->where('report_month', $month->toDateString())
|
||||
->count();
|
||||
$existingState = DB::connection($connection)->table('product_monthly_weighted_average_states')
|
||||
->where('report_month', $month->toDateString())
|
||||
->value('status');
|
||||
if (! $this->option('force') && ($existingRows > 0 || $existingState !== null)) {
|
||||
$this->error($month->format('Y-m') . ' 已存在基数或计算状态,不自动覆盖;如需人工纠正请显式使用 --force。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$inventory = $this->inventoryAggregate($connection, $month);
|
||||
$jiangxiPurchase = $this->jiangxiDeliveryPurchase($connection, $month);
|
||||
$nanchangOpening = $this->nanchangOpeningQuantity($connection, $month);
|
||||
@ -198,9 +211,13 @@ class ImportProductMonthlyWeightedAverageBase extends Command
|
||||
throw new \RuntimeException($month->format('Y-m') . ' 的进销存总表尚未导入。');
|
||||
}
|
||||
|
||||
$mappingTable = (new InventoryWarehouseNameMapping)->getConnection()->getDatabaseName()
|
||||
. '.' . DB::connection((new InventoryWarehouseNameMapping)->getConnectionName())->getTablePrefix()
|
||||
. (new InventoryWarehouseNameMapping)->getTable();
|
||||
$mappingModel = new InventoryWarehouseNameMapping();
|
||||
$mappingConnection = DB::connection($mappingModel->getConnectionName());
|
||||
$mappingTable = $mappingConnection->getDriverName() === 'sqlite'
|
||||
? $mappingModel->getTable()
|
||||
: $mappingModel->getConnection()->getDatabaseName()
|
||||
. '.' . $mappingConnection->getTablePrefix()
|
||||
. $mappingModel->getTable();
|
||||
|
||||
$sql = "select m.warehouse_short_name as short_name, r.product_code as product_code,
|
||||
sum(case when r.warehouse like '%主仓%' then r.opening_quantity else 0 end) as opening_quantity,
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
121
app/Console/Commands/ImportSupplierSummaryInbound.php
Normal file
121
app/Console/Commands/ImportSupplierSummaryInbound.php
Normal file
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\SupplierSummaryInboundImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 供应商汇总表月度增量导入:读取已解密工作簿的「明细」子表,仅追加目标月份数据。
|
||||
*/
|
||||
class ImportSupplierSummaryInbound extends Command
|
||||
{
|
||||
protected $signature = 'inventory:supplier-summary:import
|
||||
{file : 已解密的供应商汇总 XLSX 文件}
|
||||
{--month= : 目标月份 YYYY-MM,默认上一个月}
|
||||
{--result-json= : 将执行结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '将供应商汇总表「明细」增量导入入库明细数据';
|
||||
|
||||
public function handle(SupplierSummaryInboundImporter $importer): int
|
||||
{
|
||||
$month = trim((string) $this->option('month'));
|
||||
if ($month === '') {
|
||||
$month = now('Asia/Shanghai')->subMonthNoOverflow()->format('Y-m');
|
||||
}
|
||||
|
||||
$lock = Cache::lock('inventory-supplier-summary-inbound-import', 21600);
|
||||
if (! $lock->get()) {
|
||||
return $this->finish([
|
||||
'status' => 'failed',
|
||||
'report_month' => $month,
|
||||
'source_filename' => basename((string) $this->argument('file')),
|
||||
'success' => 0,
|
||||
'duplicates' => 0,
|
||||
'failed' => 1,
|
||||
'mapped' => 0,
|
||||
'total_in_table' => 0,
|
||||
'failure_reason_count' => 1,
|
||||
'failure_reasons' => [[
|
||||
'row' => 0,
|
||||
'reason' => '已有供应商汇总导入任务正在执行',
|
||||
]],
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $importer->import((string) $this->argument('file'), $month);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$result = [
|
||||
'status' => 'failed',
|
||||
'report_month' => $month,
|
||||
'source_filename' => basename((string) $this->argument('file')),
|
||||
'success' => 0,
|
||||
'duplicates' => 0,
|
||||
'failed' => 1,
|
||||
'mapped' => 0,
|
||||
'total_in_table' => 0,
|
||||
'failure_reason_count' => 1,
|
||||
'failure_reasons' => [[
|
||||
'row' => 0,
|
||||
'reason' => $exception->getMessage(),
|
||||
]],
|
||||
];
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
return $this->finish($result);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $result */
|
||||
private function finish(array $result): int
|
||||
{
|
||||
$reasons = is_array($result['failure_reasons'] ?? null)
|
||||
? $result['failure_reasons']
|
||||
: [];
|
||||
$result['failure_reason_count'] = count($reasons);
|
||||
|
||||
$json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (! is_string($json)) {
|
||||
$json = '{"status":"failed","failure_reasons":[{"row":0,"reason":"结果 JSON 编码失败"}]}';
|
||||
}
|
||||
|
||||
$resultPath = trim((string) $this->option('result-json'));
|
||||
if ($resultPath !== '') {
|
||||
File::ensureDirectoryExists(dirname($resultPath));
|
||||
File::put($resultPath, $json . PHP_EOL);
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'%s 导入状态=%s,成功=%s,重复=%s,失败=%s,目标月份总行数=%s',
|
||||
(string) ($result['report_month'] ?? ''),
|
||||
(string) ($result['status'] ?? 'failed'),
|
||||
number_format((int) ($result['success'] ?? 0)),
|
||||
number_format((int) ($result['duplicates'] ?? 0)),
|
||||
number_format((int) ($result['failed'] ?? 0)),
|
||||
number_format((int) ($result['total_in_table'] ?? 0)),
|
||||
));
|
||||
foreach (array_slice($reasons, 0, 10) as $reason) {
|
||||
$this->warn(sprintf(
|
||||
'第 %s 行:%s',
|
||||
(string) ($reason['row'] ?? ''),
|
||||
(string) ($reason['reason'] ?? '未知错误'),
|
||||
));
|
||||
}
|
||||
if (count($reasons) > 10) {
|
||||
$this->line(sprintf(
|
||||
'另有 %s 条失败原因,完整清单见结果 JSON。',
|
||||
number_format(count($reasons) - 10),
|
||||
));
|
||||
}
|
||||
|
||||
return ($result['status'] ?? 'failed') === 'success'
|
||||
? self::SUCCESS
|
||||
: self::FAILURE;
|
||||
}
|
||||
}
|
||||
41
app/Console/Commands/ImportSystemizationTodos.php
Normal file
41
app/Console/Commands/ImportSystemizationTodos.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\SystemizationTodoWorkbookImporter;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ImportSystemizationTodos extends Command
|
||||
{
|
||||
protected $signature = 'systemization-todos:import
|
||||
{file : 待办 Excel 文件路径}
|
||||
{--company=1 : 写入的 company_id}
|
||||
{--user= : 操作人用户 ID}';
|
||||
|
||||
protected $description = '导入财务系统化待办清单';
|
||||
|
||||
public function handle(SystemizationTodoWorkbookImporter $importer): int
|
||||
{
|
||||
try {
|
||||
$result = $importer->import(
|
||||
(string) $this->argument('file'),
|
||||
max((int) $this->option('company'), 1),
|
||||
$this->option('user') !== null ? (int) $this->option('user') : null,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'工作表 %s:新增 %d 条,跳过已存在 %d 条。',
|
||||
$result['sheet'],
|
||||
$result['created'],
|
||||
$result['skipped'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\StoreReceiptDefaultCompensationSnapshotBuilder;
|
||||
use Throwable;
|
||||
|
||||
class PreviewStoreReceiptDefaultCompensation extends Command
|
||||
{
|
||||
protected $signature = 'store-receipt:default-compensation:preview
|
||||
{month : 账期 YYYY-MM}
|
||||
{mapping : 最新科目映射表 xlsx 路径}
|
||||
{--output= : 输出 JSON 路径;默认写入 storage/app/local/performance-details/default-compensation}';
|
||||
|
||||
protected $description = '人工核对:按指定科目映射表生成「违约赔付(冲减收入)」金额快照,不写数据库';
|
||||
|
||||
public function handle(StoreReceiptDefaultCompensationSnapshotBuilder $builder): int
|
||||
{
|
||||
$startedAt = hrtime(true);
|
||||
$month = trim((string) $this->argument('month'));
|
||||
$mapping = (string) $this->argument('mapping');
|
||||
|
||||
try {
|
||||
$snapshot = $builder->build($month, $mapping);
|
||||
$path = (string) ($this->option('output') ?: storage_path(
|
||||
'app/local/performance-details/default-compensation/' . $month . '.json'
|
||||
));
|
||||
$directory = dirname($path);
|
||||
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
|
||||
throw new \RuntimeException('无法创建快照目录:' . $directory);
|
||||
}
|
||||
$temporary = $path . '.tmp';
|
||||
$json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
if ($json === false || file_put_contents($temporary, $json, LOCK_EX) === false) {
|
||||
throw new \RuntimeException('无法写入本地快照。');
|
||||
}
|
||||
rename($temporary, $path);
|
||||
|
||||
$this->info('本地预览快照已生成:' . $path);
|
||||
$this->line('映射条数:' . $snapshot['mapping_rows']);
|
||||
$this->line('关联行数:' . count($snapshot['amounts']));
|
||||
$this->line('金额合计:' . number_format((float) $snapshot['total'], 2, '.', ''));
|
||||
$this->line('耗时:' . number_format((hrtime(true) - $startedAt) / 1e6, 0) . ' ms');
|
||||
$this->line('PHP 峰值内存:' . number_format(memory_get_peak_usage(true) / 1024 / 1024, 1) . ' MB');
|
||||
foreach ($snapshot['source_stats'] as $platform => $stats) {
|
||||
$this->line(sprintf(
|
||||
' %s:账单行 %d,金额 %s',
|
||||
$platform,
|
||||
$stats['rows'],
|
||||
number_format((float) $stats['amount'], 2, '.', ''),
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
47
app/Console/Commands/ReportProductMonthlyWeightedAverage.php
Normal file
47
app/Console/Commands/ReportProductMonthlyWeightedAverage.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Business\ProductMonthlyWeightedAverageReporter;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class ReportProductMonthlyWeightedAverage extends Command
|
||||
{
|
||||
protected $signature = 'product:monthly-weighted-average:report
|
||||
{month : 报表月份,例如 2026-09}
|
||||
{--result-json= : 将结果写入指定 JSON 文件}';
|
||||
|
||||
protected $description = '输出商品月加权平均单价结果简报与差异分类';
|
||||
|
||||
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
|
||||
{
|
||||
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
|
||||
$report = $reporter->build(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
$month,
|
||||
);
|
||||
|
||||
$path = trim((string) $this->option('result-json'));
|
||||
if ($path !== '') {
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
File::put(
|
||||
$path,
|
||||
json_encode(
|
||||
$report,
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
|
||||
) . PHP_EOL,
|
||||
);
|
||||
}
|
||||
|
||||
$this->line($reporter->brief($report));
|
||||
if (($report['status'] ?? null) === 'review_required') {
|
||||
$this->warn('存在待人工复核差异,完整清单已写入结果 JSON。');
|
||||
}
|
||||
|
||||
return ($report['status'] ?? null) === 'missing'
|
||||
? self::FAILURE
|
||||
: self::SUCCESS;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
59
app/Console/Commands/SyncFinanceLedgerShopData.php
Normal file
59
app/Console/Commands/SyncFinanceLedgerShopData.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\FinanceLedgerShopDataSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
class SyncFinanceLedgerShopData extends Command
|
||||
{
|
||||
protected $signature = 'finance-ledger:sync-shop-data
|
||||
{--from= : 提现业务日期起,默认从台账期初日}
|
||||
{--to= : 提现业务日期止,最晚只同步到前一晚}
|
||||
{--days= : 只同步最近 N 天}
|
||||
{--no-recalculate : 只同步数据,不重算流水}';
|
||||
|
||||
protected $description = '同步上一晚店铺余额,并把平台账单中的提现对应到财务账户收入';
|
||||
|
||||
public function handle(FinanceLedgerShopDataSynchronizer $synchronizer): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?: 0);
|
||||
$from = $this->option('from') ?: null;
|
||||
if ($days > 0) {
|
||||
$from = now()->subDays(max(1, $days) - 1)->toDateString();
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $synchronizer->sync(
|
||||
$from,
|
||||
$this->option('to') ?: null,
|
||||
! $this->option('no-recalculate'),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->table(['项目', '值'], [
|
||||
['店铺余额日期', $result['balance']['date'] ?: '-'],
|
||||
['余额源记录', $result['balance']['source']],
|
||||
['补充确认店铺', $result['balance']['added']],
|
||||
['已更新店铺余额', $result['balance']['matched']],
|
||||
['店铺余额合计', number_format((float) $result['balance']['amount'], 2)],
|
||||
['提现处理笔数', $result['withdrawals']['processed']],
|
||||
['提现新增或更新', $result['withdrawals']['written']],
|
||||
['上游已存在提现', $result['withdrawals']['upstream_duplicates']],
|
||||
['店铺未匹配', $result['withdrawals']['unmatched']],
|
||||
['账户缺失', $result['withdrawals']['account_missing']],
|
||||
['提现收入合计', number_format((float) $result['withdrawals']['income'], 2)],
|
||||
['提现冲回支出', number_format((float) $result['withdrawals']['expense'], 2)],
|
||||
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
|
||||
['PHP 峰值内存', number_format(memory_get_peak_usage(true) / 1048576, 1) . ' MiB'],
|
||||
]);
|
||||
$this->info('财务台账店铺余额与提现同步完成。');
|
||||
|
||||
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
File diff suppressed because it is too large
Load Diff
@ -38,7 +38,7 @@ class SyncStoreReportMirror extends Command
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'店铺报表镜像:流程期间 %s/%d 行,主体变更 %s(检查 %d 店,新增 %d 条),账单 %s/%d 行,日汇总 %s/%d 行。',
|
||||
'店铺报表镜像:流程期间 %s/%d 行,主体变更 %s(检查 %d 店,新增 %d 条),账单 %s/%d 行,日汇总 %s/%d 行,销量汇总 %s/%d 行。',
|
||||
$result['lifecycles']['status'],
|
||||
$result['lifecycles']['source_rows'],
|
||||
$result['period_changes']['status'],
|
||||
@ -48,6 +48,8 @@ class SyncStoreReportMirror extends Command
|
||||
$result['bills']['source_rows'],
|
||||
$result['daily']['status'],
|
||||
$result['daily']['source_rows'],
|
||||
$result['paid_sales']['status'],
|
||||
$result['paid_sales']['stored_rows'],
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
51
app/Console/Commands/SyncStoreReportPaidSales.php
Normal file
51
app/Console/Commands/SyncStoreReportPaidSales.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 店铺维度报表的「销量」汇总:业绩明细「收入金额 + 优惠金额 + 冲减金额 + 退款金额」
|
||||
* 按「店铺名称 + 发生日期」预汇总。
|
||||
*
|
||||
* 业绩明细同步(store-receipt:order-match:sync)结束后会自动刷新一次;
|
||||
* 这个命令用于单独重跑(例如补数据或按最新代码强制重建)。
|
||||
*/
|
||||
class SyncStoreReportPaidSales extends Command
|
||||
{
|
||||
protected $signature = 'business-mirror:sync-store-report-paid-sales
|
||||
{--force : 忽略来源指纹,强制重建销量汇总}';
|
||||
|
||||
protected $description = '把业绩明细的收入/优惠/冲减/退款金额按店铺和发生日期汇总,供店铺维度报表销量列读取';
|
||||
|
||||
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
|
||||
{
|
||||
try {
|
||||
$result = $synchronizer->syncPaidSales((bool) $this->option('force'));
|
||||
} catch (Throwable $exception) {
|
||||
$this->error('店铺维度报表销量汇总失败:' . $exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($result['status'] === 'skipped') {
|
||||
$this->warn('镜像里还没有业绩明细表,销量汇总未执行。');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($result['status'] !== 'succeeded') {
|
||||
$this->error('店铺维度报表销量汇总失败,页面继续读取上一版数据。');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info($result['changed']
|
||||
? sprintf('销量汇总已重建:业绩明细 %d 行 → 汇总 %d 行。', $result['source_rows'], $result['stored_rows'])
|
||||
: sprintf('业绩明细没有变化,沿用当前销量汇总(%d 行)。', $result['stored_rows']));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Services\AccessoryReshipCourierImporter;
|
||||
use Modules\EcommerceFinance\Services\AccessoryReshipCourierStore;
|
||||
use Modules\EcommerceFinance\Services\AccessoryReshipRateCalculator;
|
||||
use Modules\EcommerceFinance\View\AccessoryReshipConfigPageData;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
|
||||
class AccessoryReshipCourierConfigs extends Controller
|
||||
{
|
||||
public function __construct(private readonly AccessoryReshipCourierStore $store) {}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->store->save((int) company_id(), $this->validated($request), $this->actor());
|
||||
return $this->back('快递扣款配置已新增。');
|
||||
}
|
||||
|
||||
public function update(Request $request, int $configId)
|
||||
{
|
||||
$this->store->save((int) company_id(), $this->validated($request), $this->actor(), $configId);
|
||||
return $this->back('快递扣款配置已更新,核对清单将按新比例重新计算。');
|
||||
}
|
||||
|
||||
public function destroy(int $configId)
|
||||
{
|
||||
$this->store->changeDeleted((int) company_id(), $configId, true, $this->actor());
|
||||
return $this->back('配置已删除,该快递暂不参与扣款计算。');
|
||||
}
|
||||
|
||||
public function restore(int $configId)
|
||||
{
|
||||
$this->store->changeDeleted((int) company_id(), $configId, false, $this->actor());
|
||||
return $this->back('快递扣款配置已恢复。');
|
||||
}
|
||||
|
||||
public function import(Request $request, AccessoryReshipCourierImporter $importer)
|
||||
{
|
||||
$request->validate(['file' => ['required', 'file', 'mimes:xlsx', 'max:10240']]);
|
||||
$path = $request->file('file')->getRealPath();
|
||||
$rows = $importer->read($path);
|
||||
$result = $this->store->import((int) company_id(), $rows, $this->actor(), hash_file('sha256', $path));
|
||||
return $this->back("导入完成:新增{$result['added']}条,重复跳过{$result['skipped']}条。");
|
||||
}
|
||||
|
||||
public function export(AccessoryReshipConfigPageData $data)
|
||||
{
|
||||
$rows = $this->store->filtered((int) company_id(), $data->filters())->get();
|
||||
$book = new Spreadsheet();
|
||||
$sheet = $book->getActiveSheet();
|
||||
$sheet->setTitle('快递配置');
|
||||
$sheet->fromArray([['快递代码', '快递公司', '创建时间', '创建人', '扣款比率(%)', '删除状态']]);
|
||||
foreach ($rows as $index => $row) {
|
||||
$number = $index + 2;
|
||||
foreach ([$row->courier_code, $row->courier_name, $row->source_created_at ?: $row->created_at, $row->creator] as $column => $value) {
|
||||
$sheet->setCellValueExplicit([$column + 1, $number], (string) $value, DataType::TYPE_STRING);
|
||||
}
|
||||
$sheet->setCellValue([5, $number], $row->rate_basis_points / 10000);
|
||||
$sheet->getStyle('E' . $number)->getNumberFormat()->setFormatCode('0.00%');
|
||||
$sheet->setCellValueExplicit([6, $number], $row->deleted_at ? '已删除' : '正常', DataType::TYPE_STRING);
|
||||
}
|
||||
foreach (['A' => 24, 'B' => 30, 'C' => 24, 'D' => 18, 'E' => 18, 'F' => 14] as $column => $width) {
|
||||
$sheet->getColumnDimension($column)->setWidth($width);
|
||||
}
|
||||
$sheet->freezePane('A2');
|
||||
return response()->streamDownload(function () use ($book): void {
|
||||
try { (new Xlsx($book))->save('php://output'); } finally { $book->disconnectWorksheets(); }
|
||||
}, '快递扣款配置.xlsx', ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']);
|
||||
}
|
||||
|
||||
private function validated(Request $request): array
|
||||
{
|
||||
$data = $request->validate(['courier_code' => ['required', 'string', 'max:64'], 'courier_name' => ['required', 'string', 'max:191'], 'rate_percent' => ['required', 'string']]);
|
||||
$data['rate_basis_points'] = AccessoryReshipRateCalculator::basisPoints($data['rate_percent']);
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function actor(): string
|
||||
{
|
||||
return mb_substr((string) user()->name, 0, 191);
|
||||
}
|
||||
|
||||
private function back(string $message)
|
||||
{
|
||||
return redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'accessory-reship-deductions', 'reship_view' => 'config'])->with('reship_config_message', $message);
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/Business/AccessoryReshipDeductions.php
Normal file
48
app/Http/Controllers/Business/AccessoryReshipDeductions.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\View\AccessoryReshipPageData;
|
||||
|
||||
class AccessoryReshipDeductions extends Controller
|
||||
{
|
||||
public function selectCourier(\Illuminate\Http\Request $request, AccessoryReshipPageData $data, \Modules\EcommerceFinance\Services\AccessoryReshipSelectionStore $store)
|
||||
{
|
||||
$input = $request->validate([
|
||||
'month' => ['required', 'date_format:Y-m', 'after_or_equal:2000-01'],
|
||||
'row_key' => ['required', 'regex:/^[a-f0-9]{64}$/'],
|
||||
'courier' => ['required', 'string', 'max:191'],
|
||||
]);
|
||||
// 重新读取候选,禁止提交任意快递名称或其他公司的包裹。
|
||||
$found = false;
|
||||
foreach ($data->rows(['month' => $input['month']]) as $row) {
|
||||
if ($row['selection_key'] !== $input['row_key']) continue;
|
||||
$found = in_array($input['courier'], array_column($row['courier_candidates'], 'courier'), true);
|
||||
break;
|
||||
}
|
||||
abort_unless($found, 422, '该快递已不在当前首发候选中,请刷新后重新选择。');
|
||||
$store->save((int) company_id(), $input['month'], $input['row_key'], $input['courier'], mb_substr((string) user()->name, 0, 191));
|
||||
return response()->json(['message' => '扣款快递已保存。']);
|
||||
}
|
||||
|
||||
public function export(AccessoryReshipPageData $data)
|
||||
{
|
||||
$filters = $data->filters();
|
||||
// 先完成读取,失败时返回正常错误,避免下载半份扣款清单。
|
||||
$file = tmpfile();
|
||||
if ($file === false) abort(500, '无法创建导出文件。');
|
||||
fwrite($file, "\xEF\xBB\xBF");
|
||||
fputcsv($file, ['交易单号','补发快递单号','补发快递','补发时间','原始条形码','处理后条形码','补发运费','匹配状态','首发快递公司(候选)','首发快递单号(候选)','首发所属月份(候选)','扣款比例(%)','扣款金额','说明']);
|
||||
foreach ($data->rows($filters) as $row) {
|
||||
if (!AccessoryReshipPageData::inCategory($row, $filters['match_status'])) continue;
|
||||
$values = [$row['trade_no'], $row['express_no'], ($row['express_name1'] ?: ($row['express_name2'] ?? '')), $row['ship_time'], $row['barcode'], $row['normalized_barcode'], $row['freight'], $row['deduction_state'] === 'ready' ? '可扣款' : ($row['status'] === 'matched' ? '已匹配待核对' : '未匹配不扣款'), implode(' / ', array_column($row['matches'], 'courier')), implode(' / ', array_column($row['matches'], 'express_no')), implode(' / ', array_column($row['matches'], 'month')), $row['deduction_rate_basis_points'] === null ? '' : number_format($row['deduction_rate_basis_points'] / 100, 2, '.', '') . '%', $row['deduction_cents'] === null ? '' : number_format($row['deduction_cents'] / 100, 2, '.', ''), $row['deduction_note']];
|
||||
fputcsv($file, array_map(static function ($value) {
|
||||
$value = (string) $value;
|
||||
return preg_match('/^[=+\-@\t\r\n]/u', $value) ? "'" . $value : $value;
|
||||
}, $values));
|
||||
}
|
||||
rewind($file);
|
||||
return response()->streamDownload(function () use ($file) { fpassthru($file); fclose($file); }, '配件补发扣款-' . $filters['month'] . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
}
|
||||
@ -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 直接可用的字段(变更内容与操作人只在服务端算一次)。
|
||||
*
|
||||
|
||||
@ -8,11 +8,12 @@ use App\Models\Business\CourierQuoteVersion;
|
||||
use App\Models\Business\CourierSigningSubject;
|
||||
use App\Services\Esign\EsignClient;
|
||||
use App\Support\CourierAgreementFileArchive;
|
||||
use App\Support\CourierAgreementCredentials;
|
||||
use App\Support\CourierAgreementSignatureService;
|
||||
use App\Support\CourierQuoteConfirmation;
|
||||
use App\Support\CourierQuoteSignatureService;
|
||||
use App\Support\SupplierCooperationTemplates;
|
||||
use App\Utilities\SystemAccess;
|
||||
use App\Utilities\AgreementSigningPermissions;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@ -198,6 +199,12 @@ class CourierAgreementSignatures extends Controller
|
||||
$data['operator_account'] = $data['esign_account'];
|
||||
|
||||
$data['payload'] = $this->payload($data);
|
||||
$data['payload']['credential_required'] = $request->boolean('credential_required');
|
||||
$data['payload']['credential_signer'] = (int) ($data['credential_signer'] ?? ($type === 'entity-change' ? 1 : 0));
|
||||
if ($request->boolean('credential_required') && $type === 'entity-change'
|
||||
&& $data['payload']['credential_signer'] === 0 && $data['counterparty_signer_type'] === 'person') {
|
||||
throw ValidationException::withMessages(['credential_signer' => '营业执照需要企业盖章,请选择变更后主体签署证件。']);
|
||||
}
|
||||
|
||||
$signature = $this->service->send($data, user_id());
|
||||
|
||||
@ -217,7 +224,7 @@ class CourierAgreementSignatures extends Controller
|
||||
*/
|
||||
public function storeSubject(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
$this->ensureCanAdminister();
|
||||
|
||||
if ($response = $this->subjectsTableUnavailable()) {
|
||||
return $response;
|
||||
@ -244,7 +251,7 @@ class CourierAgreementSignatures extends Controller
|
||||
|
||||
public function updateSubject(Request $request, CourierSigningSubject $subject): JsonResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
$this->ensureCanAdminister();
|
||||
$this->ensureSubjectBelongsToCurrentCompany($subject);
|
||||
|
||||
if ($response = $this->subjectsTableUnavailable()) {
|
||||
@ -265,7 +272,7 @@ class CourierAgreementSignatures extends Controller
|
||||
|
||||
public function setDefaultSubject(CourierSigningSubject $subject): JsonResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
$this->ensureCanAdminister();
|
||||
$this->ensureSubjectBelongsToCurrentCompany($subject);
|
||||
|
||||
if ($response = $this->subjectsTableUnavailable()) {
|
||||
@ -292,7 +299,7 @@ class CourierAgreementSignatures extends Controller
|
||||
|
||||
public function destroySubject(Request $request, CourierSigningSubject $subject): JsonResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
$this->ensureCanAdminister();
|
||||
$this->ensureSubjectBelongsToCurrentCompany($subject);
|
||||
|
||||
if ($response = $this->subjectsTableUnavailable()) {
|
||||
@ -364,7 +371,7 @@ class CourierAgreementSignatures extends Controller
|
||||
|
||||
private function deleteTask(Request $request, CourierAgreementSignature|CourierQuoteSignature $task): JsonResponse|RedirectResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
$this->ensureCanAdminister();
|
||||
abort_unless($task->company_id === (int) company_id(), 404);
|
||||
$request->validate(['delete_confirmed' => ['required', 'accepted']]);
|
||||
|
||||
@ -387,6 +394,16 @@ class CourierAgreementSignatures extends Controller
|
||||
public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse|RedirectResponse
|
||||
{
|
||||
$this->ensureCanManage();
|
||||
abort_unless($agreementSignature->company_id === (int) company_id(), 404);
|
||||
if (preg_match('/^credential-(license|identity|bank)-(source|signed)$/', $kind, $match)) {
|
||||
$document = $agreementSignature->credentialDocuments()[$match[1]] ?? null;
|
||||
abort_unless($document, 404);
|
||||
$path = $document[$match[2] . '_path'] ?? null;
|
||||
abort_if(blank($path) || ! \Storage::disk('uploads')->exists($path), 404, '证件推送单不存在。');
|
||||
return \Storage::disk('uploads')->response($path,
|
||||
$agreementSignature->business_no . '-' . $document['name'] . ($match[2] === 'signed' ? '-已签署' : '-原文') . '.pdf',
|
||||
['Content-Type' => 'application/pdf'], request()->boolean('preview') ? 'inline' : 'attachment');
|
||||
}
|
||||
try {
|
||||
[$path, $suffix] = match ($kind) {
|
||||
'source' => [$this->agreementFileArchive->sourcePath($agreementSignature), '签署原文'],
|
||||
@ -506,6 +523,9 @@ class CourierAgreementSignatures extends Controller
|
||||
'cooperation_party_a_phone' => ['required_if:document_type,cooperation-contract', 'string', 'max:64'],
|
||||
'cooperation_party_b_name' => ['required_if:document_type,cooperation-contract', 'string', 'max:255'],
|
||||
'cooperation_party_b_phone' => ['required_if:document_type,cooperation-contract', 'string', 'max:64'],
|
||||
'cooperation_party_b_signing_place' => ['nullable', 'string', 'max:100'],
|
||||
'cooperation_contract_number' => ['nullable', 'string', 'max:100'],
|
||||
'cooperation_party_b_legal_representative' => ['nullable', 'string', 'max:100'],
|
||||
'cooperation_start_date' => ['required_if:document_type,cooperation-contract', 'date'],
|
||||
'cooperation_end_date' => ['required_if:document_type,cooperation-contract', 'date', 'after_or_equal:cooperation_start_date'],
|
||||
'principal_name' => ['required_if:document_type,authorization', 'string', 'max:100'],
|
||||
@ -519,15 +539,21 @@ class CourierAgreementSignatures extends Controller
|
||||
'payment_counterparty' => ['required_if:document_type,authorization', 'string', 'max:255'],
|
||||
'authorization_start_date' => ['required_if:document_type,authorization', 'date'],
|
||||
'authorization_end_date' => ['required_if:document_type,authorization', 'date', 'after_or_equal:authorization_start_date'],
|
||||
] + $supplierFields;
|
||||
] + $supplierFields + CourierAgreementCredentials::rules((string) request('document_type'), request()->boolean('credential_required'));
|
||||
}
|
||||
|
||||
private function payload(array $data): array
|
||||
{
|
||||
$allowed = array_keys($this->rules());
|
||||
$payload = array_intersect_key($data, array_flip($allowed));
|
||||
unset($payload['esign_account'], $payload['idempotency_key'], $payload['operator_name']);
|
||||
unset($payload['esign_account'], $payload['idempotency_key'], $payload['operator_name'], $payload['credential_images']);
|
||||
$payload['authorization_items'] = array_values(array_map('strval', $data['authorization_items'] ?? []));
|
||||
if (($data['document_type'] ?? '') === 'cooperation-contract' && ($data['partner_type'] ?? 'courier') === 'courier') {
|
||||
$payload['cooperation_party_b_signing_place'] = trim((string) ($data['cooperation_party_b_signing_place'] ?? '')) ?: '常州市';
|
||||
$payload['cooperation_contract_number'] = trim((string) ($data['cooperation_contract_number'] ?? ''));
|
||||
$payload['cooperation_party_b_legal_representative'] = ($data['counterparty_signer_type'] ?? 'organization') === 'person'
|
||||
? null : trim((string) ($data['cooperation_party_b_legal_representative'] ?? ''));
|
||||
}
|
||||
if (($data['document_type'] ?? null) === 'entity-change') {
|
||||
$payload['former_legal_representative'] = trim((string) ($data['operator_name'] ?? ''));
|
||||
$payload['entity_proof_date'] = $data['signature_date'] ?? null;
|
||||
@ -584,12 +610,17 @@ class CourierAgreementSignatures extends Controller
|
||||
private function ensureCanManage(): void
|
||||
{
|
||||
abort_unless(
|
||||
in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true),
|
||||
AgreementSigningPermissions::canOperate(),
|
||||
403,
|
||||
'当前账号没有协议签署管理权限。',
|
||||
'当前账号没有协议签署操作权限,请联系管理员授权。',
|
||||
);
|
||||
}
|
||||
|
||||
private function ensureCanAdminister(): void
|
||||
{
|
||||
abort_unless(AgreementSigningPermissions::canAdminister(), 403, '当前账号没有协议签署管理权限。');
|
||||
}
|
||||
|
||||
private function subjectData(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
|
||||
28
app/Http/Controllers/Business/CourierContracts.php
Normal file
28
app/Http/Controllers/Business/CourierContracts.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class CourierContracts extends Controller
|
||||
{
|
||||
/** A read-only archive entry point with courier-information page access, not signing-management access. */
|
||||
public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse
|
||||
{
|
||||
abort_unless($agreementSignature->company_id === (int) company_id()
|
||||
&& $agreementSignature->archivedCourierContractPayload() !== null, 404);
|
||||
$path = match ($kind) {
|
||||
'signed' => $agreementSignature->signed_pdf_path,
|
||||
'certificate' => $agreementSignature->certificate_path,
|
||||
default => abort(404),
|
||||
};
|
||||
abort_if(blank($path) || ! Storage::disk('uploads')->exists($path), 404, '归档附件不存在,请核对协议签署记录。');
|
||||
$suffix = $kind === 'signed' ? '已签署合作合同' : '签署完成证书';
|
||||
|
||||
return Storage::disk('uploads')->response($path, $agreementSignature->business_no . '-' . $suffix . '.pdf',
|
||||
['Content-Type' => 'application/pdf'], request()->boolean('preview') ? 'inline' : 'attachment');
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ use App\Models\Business\CourierOnboarding;
|
||||
use App\Models\Business\CourierOnboardingEvent;
|
||||
use App\Models\Business\CourierOnboardingFile;
|
||||
use App\Support\CourierOnboardingService;
|
||||
use App\Support\CourierOnboardingReviewers;
|
||||
use App\Support\QrCode;
|
||||
use App\Utilities\SystemAccess;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@ -68,6 +69,9 @@ class CourierOnboardings extends Controller
|
||||
report($exception);
|
||||
}
|
||||
$onboarding->load(['files', 'events.actor', 'quoteVersion.signature']);
|
||||
$reviewers = app(CourierOnboardingReviewers::class);
|
||||
$reviewAccess = in_array($onboarding->status, ['submitted', 'first_approved', 'returned'], true)
|
||||
? $reviewers->access($onboarding, (int) user_id()) : ['allowed' => false, 'message' => '当前申请无需审核。'];
|
||||
|
||||
return view('business.pages.courier-payables.courier-onboarding-show', [
|
||||
'onboarding' => $onboarding,
|
||||
@ -76,9 +80,10 @@ class CourierOnboardings extends Controller
|
||||
'signedAgreement' => $this->service->signedAgreement($onboarding),
|
||||
'quoteSignature' => $onboarding->quoteSignature(),
|
||||
'esignReady' => $this->service->esignReady(),
|
||||
'canFinalReview' => $this->isAdmin() && (
|
||||
(int) $onboarding->first_reviewed_by !== (int) user_id() || config('courier_onboarding.allow_same_reviewer')
|
||||
),
|
||||
'canFirstReview' => $reviewAccess['allowed'],
|
||||
'canFinalReview' => $reviewAccess['allowed'],
|
||||
'reviewAccess' => $reviewAccess,
|
||||
'reviewerSettings' => $reviewers->settings(),
|
||||
'isAdmin' => $this->isAdmin(),
|
||||
'quoteVersions' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? $this->requotes()->versions($onboarding) : collect(),
|
||||
]);
|
||||
@ -269,13 +274,12 @@ class CourierOnboardings extends Controller
|
||||
|
||||
public function finalApprove(Request $request, CourierOnboarding $onboarding): RedirectResponse
|
||||
{
|
||||
$this->ensureAdmin();
|
||||
$data = $request->validate([
|
||||
'courier_name' => [$onboarding->courier_name ? 'nullable' : 'required', 'string', 'max:100'],
|
||||
'reconciler' => ['nullable', 'string', 'max:100'],
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
], ['courier_name.required' => '请填写系统快递名称(需与报价名、发货数据里的快递名称完全一致)。']);
|
||||
$this->service->finalApprove($onboarding, $data, (int) user_id());
|
||||
$onboarding = $this->service->finalApprove($onboarding, $data, (int) user_id());
|
||||
|
||||
if (! $onboarding->quote_version_id) {
|
||||
return $this->back($onboarding, '财务复审通过。快递没有填写报价明细,请在报价管理补录后再发起报价确认。');
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Models\Business\CourierQuoteChange;
|
||||
use App\Models\Business\CourierStockQuoteEntry;
|
||||
use App\Services\Esign\EsignClient;
|
||||
use App\Support\CourierQuoteChangeService;
|
||||
use App\Support\CourierOnboardingService;
|
||||
@ -47,7 +48,7 @@ class CourierQuoteChanges extends Controller
|
||||
$source = $this->service->findSourceQuote($selectedId);
|
||||
|
||||
// 已入驻快递的报价走后台变价页(可改价格,也可改计费方式),推送按入驻资料签署。
|
||||
if ($request->has('source_quote_id') && ($onboarding = $this->requotes->onboardingForCourier($source['company_name'] ?? null))) {
|
||||
if (empty($source['courier_expcode']) && $request->has('source_quote_id') && ($onboarding = $this->requotes->onboardingForCourier($source['company_name'] ?? null))) {
|
||||
return redirect()->route('business.courier-onboardings.requote', $onboarding);
|
||||
}
|
||||
|
||||
@ -66,16 +67,18 @@ class CourierQuoteChanges extends Controller
|
||||
{
|
||||
$this->refreshSignatures();
|
||||
$quoteChange->load(['items', 'events.actor', 'version.signature']);
|
||||
$onboarding = $quoteChange->hasQuotePayload() ? $this->requotes->onboardingForCourier($quoteChange->carrier_name) : null;
|
||||
$stockEntry = $quoteChange->isStockQuote() ? CourierStockQuoteEntry::query()->where('source_change_id', $quoteChange->id)->firstOrFail() : null;
|
||||
$onboarding = $stockEntry === null && $quoteChange->hasQuotePayload() ? $this->requotes->onboardingForCourier($quoteChange->carrier_name) : null;
|
||||
$requoteDiff = $onboarding !== null
|
||||
? $this->requotes->diff($onboarding, $this->requotes->basePayload($onboarding, $quoteChange), $quoteChange->quote_payload)
|
||||
: null;
|
||||
|
||||
return view('business.pages.courier-payables.quote-change-show', [
|
||||
'change' => $quoteChange,
|
||||
'stockEntry' => $stockEntry,
|
||||
'onboarding' => $onboarding,
|
||||
'requoteDiff' => $requoteDiff,
|
||||
'reviewWarnings' => $quoteChange->hasQuotePayload() && $quoteChange->status !== CourierQuoteChange::STATUS_APPROVED
|
||||
'reviewWarnings' => $stockEntry === null && $quoteChange->hasQuotePayload() && $quoteChange->status !== CourierQuoteChange::STATUS_APPROVED
|
||||
? $this->requotes->reviewWarnings($quoteChange, $requoteDiff)
|
||||
: [],
|
||||
'voidedBy' => $quoteChange->version?->voided_by ? user_model_class()::query()->find($quoteChange->version->voided_by)?->name : null,
|
||||
@ -89,6 +92,9 @@ class CourierQuoteChanges extends Controller
|
||||
public function edit(CourierQuoteChange $quoteChange): View|RedirectResponse
|
||||
{
|
||||
abort_unless($quoteChange->isEditable() && $quoteChange->created_by === user_id(), 403);
|
||||
if ($quoteChange->isStockQuote()) {
|
||||
return redirect()->route('business.courier-stock-quotes.edit', data_get($quoteChange->quote_payload, 'stock_quote_entry_id'));
|
||||
}
|
||||
if ($quoteChange->hasQuotePayload() && ($onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name))) {
|
||||
return redirect()->route('business.courier-onboardings.requote', $onboarding);
|
||||
}
|
||||
@ -101,6 +107,7 @@ class CourierQuoteChanges extends Controller
|
||||
|
||||
public function update(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
|
||||
{
|
||||
abort_if($quoteChange->isStockQuote(), 422, '请使用存量报价草稿入口修改。');
|
||||
$change = $this->service->update($quoteChange, $this->validatedChange($request, false), user_id());
|
||||
|
||||
return redirect()->route('business.courier-quote-changes.edit', $change)
|
||||
@ -115,13 +122,21 @@ class CourierQuoteChanges extends Controller
|
||||
->with('success', "{$change->number} 已提交审核。");
|
||||
}
|
||||
|
||||
public function approve(CourierQuoteChange $quoteChange): RedirectResponse
|
||||
public function approve(Request $request, CourierQuoteChange $quoteChange): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
|
||||
$change = $this->service->approve($quoteChange, user_id());
|
||||
if ($quoteChange->isStockQuote()) {
|
||||
$request->validate(['stock_rules_confirmed' => ['accepted']], ['stock_rules_confirmed.accepted' => '请先核对原报价、计费规则及实际执行日期。']);
|
||||
}
|
||||
$change = $this->service->approve($quoteChange, user_id(), $request->boolean('stock_signature_verified'));
|
||||
|
||||
$message = "审核完成,已生成新报价版本 {$change->version->version_code}。";
|
||||
|
||||
if ($change->isStockQuote()) {
|
||||
return redirect()->route('business.courier-quote-changes.show', $change)
|
||||
->with('success', $message . ' ' . $change->version->signature_status . '。原报价已保留,未自动发起电子签署或发送提醒。');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->signatureService->prepareVersion($change->version, user_id());
|
||||
$message .= ' 签署 PDF 已生成并固定校验值。';
|
||||
@ -150,10 +165,46 @@ 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
|
||||
{
|
||||
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
|
||||
abort_if($quoteChange->isStockQuote(), 422, '存量报价请先核对历史签署资料,补签通过详情页手动发起。');
|
||||
$onboarding = $this->requotes->onboardingForCourier($quoteChange->carrier_name);
|
||||
abort_if($onboarding === null || $quoteChange->version === null, 404);
|
||||
app(CourierOnboardingService::class)->pushVersion($onboarding, $quoteChange->version, (int) user_id());
|
||||
|
||||
229
app/Http/Controllers/Business/CourierStockQuoteEntries.php
Normal file
229
app/Http/Controllers/Business/CourierStockQuoteEntries.php
Normal file
@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Models\Business\CourierStockQuoteEntry;
|
||||
use App\Support\CourierStockQuoteBatchService;
|
||||
use App\Support\CourierStockQuoteEntryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class CourierStockQuoteEntries extends Controller
|
||||
{
|
||||
public function __construct(private readonly CourierStockQuoteEntryService $service, private readonly CourierStockQuoteBatchService $batches)
|
||||
{
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('business.pages.courier-payables.stock-quote-batch', ['batch' => null, 'manifest' => null, 'rows' => [], 'couriers' => [], 'templates' => $this->batches->ruleTemplates()]);
|
||||
}
|
||||
|
||||
public function single(): View
|
||||
{
|
||||
return view('business.pages.courier-payables.stock-quote-entry', ['entry' => null, 'couriers' => $this->service->courierOptions()]);
|
||||
}
|
||||
|
||||
public function previewBatch(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'source_quote' => ['required', 'file', 'mimes:xlsx,xls', 'max:20480'],
|
||||
'quote_type' => ['required', 'string', 'max:64'],
|
||||
'effective_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'billing_rules' => ['nullable', 'string', 'max:4000'],
|
||||
]);
|
||||
$batch = $this->batches->preview($data['source_quote'], collect($data)->except('source_quote')->all(), (int) user_id());
|
||||
|
||||
return redirect()->route('business.courier-stock-quotes.batch', $batch);
|
||||
}
|
||||
|
||||
public function showBatch(string $batch): View
|
||||
{
|
||||
$manifest = $this->batches->load($batch, (int) user_id());
|
||||
$couriers = $manifest['result'] === null ? $this->service->courierOptions() : [];
|
||||
|
||||
return view('business.pages.courier-payables.stock-quote-batch', [
|
||||
'batch' => $batch, 'manifest' => $manifest, 'couriers' => $couriers,
|
||||
'rows' => $manifest['result'] === null ? $this->batches->rows($manifest, $couriers) : [], 'templates' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveBatch(Request $request, string $batch): RedirectResponse|JsonResponse
|
||||
{
|
||||
// JSON 请求体不受 max_input_vars 限制,防火墙也可逐字段检查,避免跨字段误匹配。
|
||||
// 保留旧表单入口,已打开的页面仍由原有防护检查,不豁免任何用户输入。
|
||||
if ($request->isJson()) {
|
||||
$selection = $request->json()->all();
|
||||
} else {
|
||||
$request->validate(['selection_json' => ['required', 'json', 'max:200000']]);
|
||||
$selection = json_decode($request->input('selection_json'), true);
|
||||
}
|
||||
if (! is_array($selection)) {
|
||||
throw ValidationException::withMessages(['selection_json' => '请选择本次要录入的工作表。']);
|
||||
}
|
||||
foreach (['quote_type', 'summary'] as $field) {
|
||||
if (is_string($selection[$field] ?? null)) {
|
||||
$selection[$field] = trim($selection[$field]);
|
||||
}
|
||||
}
|
||||
$data = Validator::make($selection, [
|
||||
'quote_type' => ['required', 'string', 'max:64'],
|
||||
'effective_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'summary' => ['required', 'string', 'min:2', 'max:2000'],
|
||||
'rule_groups' => ['required', 'array', 'min:1', 'max:10'],
|
||||
'rule_groups.*' => ['array'],
|
||||
'rule_groups.*.name' => ['required', 'string', 'max:100'],
|
||||
'rule_groups.*.rules' => ['nullable', 'string', 'max:4000'],
|
||||
'entries' => ['required', 'array', 'max:200'],
|
||||
'entries.*' => ['array'],
|
||||
'entries.*.selected' => ['required', 'boolean'],
|
||||
'entries.*.courier_expcode' => ['nullable', 'string', 'max:64'],
|
||||
'entries.*.courier_name' => ['nullable', 'string', 'max:255'],
|
||||
'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'],
|
||||
'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'entries.*.end_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'entries.*.periods' => ['sometimes', 'array', 'min:1', 'max:20'],
|
||||
'entries.*.periods.*' => ['array'],
|
||||
'entries.*.periods.*.effective_date' => ['required', 'date_format:Y-m-d'],
|
||||
'entries.*.periods.*.end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:entries.*.periods.*.effective_date'],
|
||||
'submit_for_review' => ['required', 'boolean'],
|
||||
'confirmed' => ['accepted_if:submit_for_review,true'],
|
||||
])->validate();
|
||||
$this->batches->save($batch, (int) user_id(), $data);
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['redirect' => route('business.courier-stock-quotes.batch', $batch)]);
|
||||
}
|
||||
|
||||
return redirect()->route('business.courier-stock-quotes.batch', $batch);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
...$this->metadataRules(true),
|
||||
'courier_expcode' => ['nullable', 'string', 'max:64'],
|
||||
'courier_name' => ['required_without:courier_expcode', 'nullable', 'string', 'max:255'],
|
||||
'quote_type' => ['required', 'string', 'max:64'],
|
||||
'sheet_name' => ['nullable', 'string', 'max:255'],
|
||||
...$this->fileRules(true),
|
||||
]);
|
||||
$entries = $this->service->createPeriods($data, (int) user_id());
|
||||
if (count($entries) > 1) {
|
||||
return redirect()->route('business.courier-quote-changes.index')
|
||||
->with('success', '已按 ' . count($entries) . ' 个执行区间保存报价草稿,共用原报价与附件;请分别核对并提交审核。');
|
||||
}
|
||||
$entry = $entries[0];
|
||||
|
||||
return redirect()->route('business.courier-quote-changes.show', $entry->change)
|
||||
->with('success', '存量报价草稿已保存。请核对原报价、计费规则和实际执行日期后提交审核。');
|
||||
}
|
||||
|
||||
public function edit(CourierStockQuoteEntry $stockQuoteEntry): View
|
||||
{
|
||||
$this->ensureEditable($stockQuoteEntry);
|
||||
|
||||
return view('business.pages.courier-payables.stock-quote-entry', ['entry' => $stockQuoteEntry, 'couriers' => []]);
|
||||
}
|
||||
|
||||
public function update(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse
|
||||
{
|
||||
$this->ensureEditable($stockQuoteEntry);
|
||||
$data = $request->validate([...$this->metadataRules(), ...$this->fileRules(false), 'sheet_name' => ['nullable', 'string', 'max:255'], 'courier_name' => ['sometimes', 'required', 'string', 'max:255']]);
|
||||
$this->service->update($stockQuoteEntry, $data, (int) user_id());
|
||||
|
||||
return redirect()->route('business.courier-quote-changes.show', $stockQuoteEntry->change)->with('success', '草稿已更新,历次原报价及附件已保留。请重新核对后提交审核。');
|
||||
}
|
||||
|
||||
public function batchImage(Request $request, string $batch, int $sheet, int $file): StreamedResponse
|
||||
{
|
||||
$manifest = $this->batches->load($batch, (int) user_id());
|
||||
$attachment = $manifest['sheets'][$sheet]['signed_images'][$file] ?? null;
|
||||
|
||||
return $this->attachmentResponse($attachment, $request->boolean('preview'));
|
||||
}
|
||||
|
||||
public function courierNames(Request $request, ShippingOrderRepository $shipping): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['q' => ['required', 'string', 'min:2', 'max:64']]);
|
||||
try {
|
||||
return response()->json(['items' => $shipping->recentCourierNames(trim($data['q']), 20)]);
|
||||
} catch (\Throwable $exception) {
|
||||
return response()->json(['items' => [], 'message' => '发货名称暂不可用,可直接手填。'], 503);
|
||||
}
|
||||
}
|
||||
|
||||
public function download(Request $request, CourierStockQuoteEntry $stockQuoteEntry, int $file): StreamedResponse
|
||||
{
|
||||
abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404);
|
||||
$attachment = $stockQuoteEntry->files[$file] ?? null;
|
||||
|
||||
return $this->attachmentResponse($attachment, $request->boolean('preview'));
|
||||
}
|
||||
|
||||
private function attachmentResponse(?array $attachment, bool $preview): StreamedResponse
|
||||
{
|
||||
$disk = Storage::disk('uploads');
|
||||
abort_if($attachment === null || ! $disk->exists($attachment['path']), 404, '附件不存在。');
|
||||
$headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'];
|
||||
if ($preview) {
|
||||
$mime = $attachment['mime_type'] ?? $disk->mimeType($attachment['path']);
|
||||
abort_unless(in_array($mime, ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true), 415, '此格式请下载原文件查看。');
|
||||
|
||||
return $disk->response($attachment['path'], $attachment['name'], [...$headers, 'Content-Type' => $mime]);
|
||||
}
|
||||
|
||||
return $disk->download($attachment['path'], $attachment['name'], $headers);
|
||||
}
|
||||
|
||||
private function metadataRules(bool $multiplePeriods = false): array
|
||||
{
|
||||
return [
|
||||
...($multiplePeriods ? [
|
||||
'periods' => ['sometimes', 'array', 'min:1', 'max:20'],
|
||||
'periods.*' => ['array'],
|
||||
'periods.*.effective_date' => ['required', 'date_format:Y-m-d'],
|
||||
'periods.*.end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:periods.*.effective_date'],
|
||||
] : []),
|
||||
'effective_date' => [$multiplePeriods ? 'required_without:periods' : 'required', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
|
||||
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
|
||||
'summary' => ['required', 'string', 'min:2', 'max:2000'],
|
||||
];
|
||||
}
|
||||
|
||||
private function fileRules(bool $requiresSource): array
|
||||
{
|
||||
return [
|
||||
'source_quote' => [$requiresSource ? 'required' : 'nullable', 'file', 'mimes:xlsx,xls', 'max:20480'],
|
||||
'confirmation_files' => ['nullable', 'array', 'max:10'],
|
||||
'confirmation_files.*' => ['file', 'mimes:pdf,doc,docx,jpg,jpeg,png,xlsx,xls,eml,msg', 'max:20480'],
|
||||
'signed_files' => ['nullable', 'array', 'max:10'],
|
||||
'signed_files.*' => ['file', 'mimes:pdf,doc,docx,jpg,jpeg,png', 'max:20480'],
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureEditable(CourierStockQuoteEntry $entry): void
|
||||
{
|
||||
abort_unless($entry->company_id === (int) company_id() && $entry->change?->isEditable() && $entry->change->created_by === user_id(), 403);
|
||||
}
|
||||
|
||||
public function periodEnd(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse
|
||||
{
|
||||
abort_unless(in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::SUPER_ADMIN, \App\Utilities\SystemAccess::ADMIN], true), 403);
|
||||
abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404);
|
||||
$data = $request->validate(['end_date' => ['required', 'date_format:Y-m-d'], 'reason' => ['required', 'string', 'min:2', 'max:1000']]);
|
||||
$this->service->setPeriodEnd($stockQuoteEntry, $data['end_date'], $data['reason'], (int) user_id());
|
||||
return redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management'])->with('success', '执行截止日期已保存,报价原文和签署原件保持不变。');
|
||||
}
|
||||
}
|
||||
1400
app/Http/Controllers/Business/FinanceLedger.php
Normal file
1400
app/Http/Controllers/Business/FinanceLedger.php
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,7 @@ use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Services\PayablesFinalImporter;
|
||||
use Modules\EcommerceFinance\Services\PayablesFinalImportRunner;
|
||||
use Modules\EcommerceFinance\Services\PayablesFinalRegistry;
|
||||
use Modules\EcommerceFinance\Services\PayablesFieldNormalizer;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableImportTemplate;
|
||||
@ -70,6 +71,38 @@ class PayablesFinalActions extends Controller
|
||||
: back()->with('success', $message);
|
||||
}
|
||||
|
||||
/** 大文件导入第 1 步:上传文件并建立行缓存(前端随后按片拉取处理)。 */
|
||||
public function importUpload(Request $request, PayablesFinalImportRunner $runner): JsonResponse
|
||||
{
|
||||
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200']]);
|
||||
|
||||
$prepared = $runner->prepare(
|
||||
$request->file('import'),
|
||||
$this->fields($request),
|
||||
$this->storeDataset($request),
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
return response()->json($prepared + ['offset' => 0, 'created' => 0, 'updated' => 0, 'done' => false]);
|
||||
}
|
||||
|
||||
/** 大文件导入第 2 步起:每次处理一片,前端拿到进度后继续。 */
|
||||
public function importChunk(Request $request, PayablesFinalImportRunner $runner): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'token' => ['required', 'string', 'regex:/^[a-f0-9]{32}$/'],
|
||||
'offset' => ['required', 'integer', 'min:0'],
|
||||
]);
|
||||
|
||||
return response()->json($runner->process(
|
||||
$data['token'],
|
||||
(int) $data['offset'],
|
||||
null,
|
||||
(int) (company_id() ?: 1),
|
||||
));
|
||||
}
|
||||
|
||||
public function update(Request $request, string $recordKey): JsonResponse|RedirectResponse
|
||||
{
|
||||
$values = $this->normalizer->normalize($this->fields($request), (array) $request->input('values', []));
|
||||
|
||||
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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
264
app/Http/Controllers/Business/SupplierReconciliation.php
Normal file
264
app/Http/Controllers/Business/SupplierReconciliation.php
Normal file
@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Services\Auth\SupplierPortalAccess;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Services\SupplierComparisonCsvExporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
|
||||
use Modules\EcommerceFinance\View\SupplierReconciliationPageData;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 供应商对账:对账单模板下载 + 厂家对账单上传(严格按模板校验)。
|
||||
*/
|
||||
class SupplierReconciliation extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SupplierStatementTemplate $template,
|
||||
private readonly SupplierStatementStore $statements,
|
||||
private readonly SupplierReturnConfirmation $returnConfirmations,
|
||||
private readonly SupplierReconciliationPageData $pageData,
|
||||
private readonly SupplierReturnCsvExporter $returnCsvExporter,
|
||||
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
|
||||
private readonly SupplierComparisonCsvExporter $comparisonCsvExporter,
|
||||
private readonly SupplierPortalAccess $supplierPortalAccess,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
}
|
||||
|
||||
public function template(): BinaryFileResponse
|
||||
{
|
||||
$path = $this->template->path();
|
||||
abort_unless(is_file($path), 404, '对账单模板不存在。');
|
||||
|
||||
return response()->download($path, $this->template->filename());
|
||||
}
|
||||
|
||||
public function upload(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'],
|
||||
]);
|
||||
|
||||
$supplier = $this->effectiveSupplier($request);
|
||||
|
||||
$records = $this->template->parse($request->file('import'), $supplier);
|
||||
$rows = $this->statements->save(
|
||||
$supplier,
|
||||
$records,
|
||||
(string) $request->file('import')->getClientOriginalName(),
|
||||
(int) (company_id() ?: 1),
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
$message = "对账单已保存:{$supplier} 共 {$rows} 行。";
|
||||
|
||||
return $request->expectsJson()
|
||||
? response()->json(['message' => $message, 'rows' => $rows])
|
||||
: back()->with('success', $message);
|
||||
}
|
||||
|
||||
public function confirmReturn(Request $request): RedirectResponse
|
||||
{
|
||||
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_CONFIRMED, '已确认该行退货扣减。');
|
||||
}
|
||||
|
||||
public function disputeReturn(Request $request): RedirectResponse
|
||||
{
|
||||
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_DISPUTED, '已提交异议,复核通过前该行暂不扣减。');
|
||||
}
|
||||
|
||||
public function confirmAllReturns(Request $request): RedirectResponse
|
||||
{
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$keys = array_values(array_map(
|
||||
static fn (array $row): string => (string) ($row['_action_key'] ?? ''),
|
||||
$this->pageData->returnRowsForFilters($supplier, $companyId, $request->all()),
|
||||
));
|
||||
$confirmed = $this->returnConfirmations->confirmAll(
|
||||
$supplier,
|
||||
$keys,
|
||||
$companyId,
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
return back()->with('success', "已全部确认:{$confirmed} 行退货扣减生效(异议行保持暂不扣减)。");
|
||||
}
|
||||
|
||||
public function exportReturns(Request $request): StreamedResponse
|
||||
{
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$rows = $this->pageData->returnRowsForFilters($supplier, $companyId, $request->query());
|
||||
|
||||
return $this->returnCsvExporter->download($rows, $supplier);
|
||||
}
|
||||
|
||||
public function exportComparison(Request $request): StreamedResponse
|
||||
{
|
||||
$request->validate([
|
||||
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
|
||||
'reconciler' => ['nullable', 'string', 'max:191'],
|
||||
]);
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->query());
|
||||
|
||||
return $this->comparisonCsvExporter->download(
|
||||
$detail,
|
||||
$supplier,
|
||||
trim((string) $request->query('month', '')),
|
||||
trim((string) $request->query('reconciler', '')),
|
||||
);
|
||||
}
|
||||
|
||||
public function saveDingTalkRobot(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'supplier' => ['required', 'string', 'max:191'],
|
||||
'dingtalk_webhook' => ['nullable', 'string', 'max:2048'],
|
||||
'dingtalk_secret' => ['nullable', 'string', 'max:256'],
|
||||
'dingtalk_at_all' => ['nullable', 'boolean'],
|
||||
]);
|
||||
[$supplier] = $this->returnTarget($request);
|
||||
$this->dingTalkNotifier->save(
|
||||
$supplier,
|
||||
(string) $request->input('dingtalk_webhook', ''),
|
||||
(string) $request->input('dingtalk_secret', ''),
|
||||
$request->boolean('dingtalk_at_all'),
|
||||
);
|
||||
|
||||
return back()->with('success', "{$supplier} 的钉钉群机器人已保存。");
|
||||
}
|
||||
|
||||
public function pushDifferences(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'supplier' => ['required', 'string', 'max:191'],
|
||||
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
|
||||
'reconciler' => ['nullable', 'string', 'max:191'],
|
||||
'scope' => ['required', 'in:all,row,selected'],
|
||||
'purchase_no' => ['nullable', 'string', 'max:191'],
|
||||
'date' => ['nullable', 'string', 'max:32'],
|
||||
'barcode' => ['nullable', 'string', 'max:191'],
|
||||
'difference_type' => ['nullable', 'string', 'max:32'],
|
||||
'selected_rows' => ['nullable', 'array', 'max:600'],
|
||||
'selected_rows.*' => ['string', 'max:2048'],
|
||||
]);
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->all());
|
||||
$differences = array_values(array_filter(
|
||||
$detail['rows'],
|
||||
static fn (array $row): bool => ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH,
|
||||
));
|
||||
|
||||
if ($request->input('scope') === 'row') {
|
||||
$purchaseNo = trim((string) $request->input('purchase_no'));
|
||||
$date = trim((string) $request->input('date'));
|
||||
$barcode = trim((string) $request->input('barcode'));
|
||||
$type = trim((string) $request->input('difference_type'));
|
||||
$differences = array_values(array_filter(
|
||||
$differences,
|
||||
static fn (array $row): bool => (string) ($row['purchaseNo'] ?? '') === $purchaseNo
|
||||
&& (string) ($row['date'] ?? '') === $date
|
||||
&& (string) ($row['barcode'] ?? '') === $barcode
|
||||
&& (string) ($row['type'] ?? '') === $type
|
||||
));
|
||||
} elseif ($request->input('scope') === 'selected') {
|
||||
$selected = [];
|
||||
foreach ((array) $request->input('selected_rows', []) as $token) {
|
||||
$row = json_decode((string) $token, true);
|
||||
if (is_array($row)) {
|
||||
$selected[$this->differenceRowKey($row)] = true;
|
||||
}
|
||||
}
|
||||
$differences = array_values(array_filter(
|
||||
$differences,
|
||||
fn (array $row): bool => isset($selected[$this->differenceRowKey($row)]),
|
||||
));
|
||||
}
|
||||
|
||||
if ($differences === []) {
|
||||
return back()->withErrors(['dingtalk_push' => '当前筛选没有可推送的差异。']);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->dingTalkNotifier->push($supplier, $differences, [
|
||||
'month' => (string) $request->input('month', ''),
|
||||
'reconciler' => (string) $request->input('reconciler', ''),
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
return back()->withErrors(['dingtalk_push' => '推送失败:' . $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with(
|
||||
'success',
|
||||
"已推送 {$result['rows']} 条差异到 {$supplier} 钉钉群({$result['messages']} 条消息)。",
|
||||
);
|
||||
}
|
||||
|
||||
private function markReturn(Request $request, string $status, string $message): RedirectResponse
|
||||
{
|
||||
$request->validate(['key' => ['required', 'string', 'max:191']]);
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$key = (string) $request->input('key');
|
||||
$owned = collect($this->pageData->returnRows($supplier, $companyId))
|
||||
->contains(static fn (array $row): bool => (string) ($row['_action_key'] ?? '') === $key);
|
||||
abort_unless($owned, 403, '该退货明细不属于当前厂家账号。');
|
||||
|
||||
$this->returnConfirmations->mark(
|
||||
$supplier,
|
||||
$key,
|
||||
$status,
|
||||
$companyId,
|
||||
user_id() ? (int) user_id() : null,
|
||||
);
|
||||
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:int} */
|
||||
private function returnTarget(Request $request): array
|
||||
{
|
||||
return [$this->effectiveSupplier($request), (int) (company_id() ?: 1)];
|
||||
}
|
||||
|
||||
private function effectiveSupplier(Request $request): string
|
||||
{
|
||||
$user = $request->user();
|
||||
if ($user && SupplierPortalAccess::isSupplierUser($user)) {
|
||||
$supplier = $this->supplierPortalAccess->supplierFor($user);
|
||||
abort_if(! $supplier, 403, '当前钉钉账号未绑定有效供应商,请联系财务人员。');
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
$request->validate(['supplier' => ['required', 'string', 'max:191']]);
|
||||
$supplier = trim((string) $request->input('supplier'));
|
||||
abort_if($supplier === '', 422, '请选择厂家。');
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function differenceRowKey(array $row): string
|
||||
{
|
||||
return implode("\0", [
|
||||
trim((string) ($row['purchaseNo'] ?? $row['purchase_no'] ?? '')),
|
||||
trim((string) ($row['date'] ?? '')),
|
||||
trim((string) ($row['barcode'] ?? '')),
|
||||
trim((string) ($row['type'] ?? '')),
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
382
app/Http/Controllers/Business/SystemizationTodos.php
Normal file
382
app/Http/Controllers/Business/SystemizationTodos.php
Normal file
@ -0,0 +1,382 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Business\SystemizationTodoWorkbookImporter;
|
||||
use App\Models\Business\SystemizationTodo;
|
||||
use App\Models\Business\SystemizationTodoAttachment;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
class SystemizationTodos extends Controller
|
||||
{
|
||||
private const OVERDUE_FILTER = '__overdue__';
|
||||
|
||||
public function page(): array
|
||||
{
|
||||
$filters = $this->filters();
|
||||
$query = SystemizationTodo::query()
|
||||
->with('attachments')
|
||||
->withCount('attachments');
|
||||
|
||||
if ($filters['status'] === self::OVERDUE_FILTER) {
|
||||
$query->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED])
|
||||
->whereNotNull('due_date')
|
||||
->whereDate('due_date', '<', today());
|
||||
} elseif ($filters['status'] !== '') {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
if ($filters['category'] !== '') {
|
||||
$query->where('category', $filters['category']);
|
||||
}
|
||||
if ($filters['owners'] !== []) {
|
||||
$query->where(function ($builder) use ($filters): void {
|
||||
foreach ($filters['owners'] as $owner) {
|
||||
$builder->orWhere(
|
||||
'owner',
|
||||
'like',
|
||||
'%' . addcslashes($owner, '%_\\') . '%',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
if ($filters['assignees'] !== []) {
|
||||
$query->where(function ($builder) use ($filters): void {
|
||||
foreach ($filters['assignees'] as $assignee) {
|
||||
$builder->orWhere(
|
||||
'assignee',
|
||||
'like',
|
||||
'%' . addcslashes($assignee, '%_\\') . '%',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
if ($filters['start_date'] !== '') {
|
||||
$query->whereDate('start_date', $filters['start_date']);
|
||||
}
|
||||
if ($filters['due_date'] !== '') {
|
||||
$query->whereDate('due_date', $filters['due_date']);
|
||||
}
|
||||
if ($filters['keyword'] !== '') {
|
||||
$like = '%' . addcslashes($filters['keyword'], '%_\\') . '%';
|
||||
$query->where(function ($builder) use ($like): void {
|
||||
$builder->where('title', 'like', $like)
|
||||
->orWhere('category', 'like', $like)
|
||||
->orWhere('section', 'like', $like)
|
||||
->orWhere('owner', 'like', $like)
|
||||
->orWhere('assignee', 'like', $like)
|
||||
->orWhere('progress', 'like', $like)
|
||||
->orWhere('notes', 'like', $like);
|
||||
});
|
||||
}
|
||||
|
||||
$items = $query
|
||||
->select($query->qualifyColumn('*'))
|
||||
->selectRaw(
|
||||
"ROW_NUMBER() OVER (
|
||||
PARTITION BY COALESCE(NULLIF(category, ''), '__uncategorized__')
|
||||
ORDER BY
|
||||
CASE WHEN status = '已完成' THEN 1 ELSE 0 END,
|
||||
CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END,
|
||||
section,
|
||||
CASE WHEN due_date IS NULL THEN 1 ELSE 0 END,
|
||||
due_date,
|
||||
sort_order,
|
||||
id
|
||||
) AS category_sequence"
|
||||
)
|
||||
->orderByRaw("CASE WHEN status = '已完成' THEN 1 ELSE 0 END")
|
||||
->orderByRaw("CASE WHEN category IS NULL OR category = '' THEN 1 ELSE 0 END")
|
||||
->orderBy('category')
|
||||
->orderByRaw("CASE WHEN section IS NULL OR section = '' THEN 1 ELSE 0 END")
|
||||
->orderBy('section')
|
||||
->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END')
|
||||
->orderBy('due_date')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$base = SystemizationTodo::query();
|
||||
$categories = SystemizationTodo::query()
|
||||
->whereNotNull('category')
|
||||
->where('category', '<>', '')
|
||||
->distinct()
|
||||
->orderBy('category')
|
||||
->pluck('category');
|
||||
$statusCounts = (clone $base)
|
||||
->selectRaw('status, COUNT(*) AS aggregate')
|
||||
->groupBy('status')
|
||||
->pluck('aggregate', 'status');
|
||||
$overdueCount = (clone $base)
|
||||
->whereNotIn('status', [SystemizationTodo::STATUS_COMPLETED])
|
||||
->whereNotNull('due_date')
|
||||
->whereDate('due_date', '<', today())
|
||||
->count();
|
||||
$statusOptions = collect();
|
||||
if ($overdueCount > 0) {
|
||||
$statusOptions->push([
|
||||
'value' => self::OVERDUE_FILTER,
|
||||
'label' => '已逾期',
|
||||
]);
|
||||
}
|
||||
foreach (SystemizationTodo::STATUSES as $status) {
|
||||
if ((int) $statusCounts->get($status, 0) > 0) {
|
||||
$statusOptions->push([
|
||||
'value' => $status,
|
||||
'label' => $status,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.systemization-todos',
|
||||
'title' => '系统化待办',
|
||||
'items' => $items,
|
||||
'todoFilters' => $filters,
|
||||
'todoCategories' => $categories,
|
||||
'todoOwners' => $this->peopleOptions('owner'),
|
||||
'todoAssignees' => $this->peopleOptions('assignee'),
|
||||
'todoStatuses' => SystemizationTodo::STATUSES,
|
||||
'todoStatusOptions' => $statusOptions,
|
||||
'todoSummary' => [
|
||||
'total' => (clone $base)->count(),
|
||||
'pending' => (int) $statusCounts->get(SystemizationTodo::STATUS_PENDING, 0),
|
||||
'in_progress' => (int) $statusCounts->get(SystemizationTodo::STATUS_IN_PROGRESS, 0),
|
||||
'completed' => (int) $statusCounts->get(SystemizationTodo::STATUS_COMPLETED, 0),
|
||||
'overdue' => $overdueCount,
|
||||
],
|
||||
'todoRoutes' => [
|
||||
'store' => route('business.systemization-todos.store'),
|
||||
'update' => route('business.systemization-todos.update', ['todo' => '__TODO__']),
|
||||
'destroy' => route('business.systemization-todos.destroy', ['todo' => '__TODO__']),
|
||||
'attachment' => route('business.systemization-todos.attachments.show', [
|
||||
'todo' => '__TODO__',
|
||||
'attachment' => '__ATTACHMENT__',
|
||||
]),
|
||||
'import' => route('business.systemization-todos.import'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $this->validated($request);
|
||||
$data['company_id'] = (int) company_id();
|
||||
$data['sort_order'] = (int) SystemizationTodo::query()->max('sort_order') + 1;
|
||||
$data['created_by'] = user_id();
|
||||
$data['updated_by'] = user_id();
|
||||
$this->normalizeCompletionDates($data);
|
||||
$todo = SystemizationTodo::query()->create($data);
|
||||
$this->storeAttachments($request, $todo);
|
||||
|
||||
return $this->backToPage('待办事项已新增。');
|
||||
}
|
||||
|
||||
public function update(Request $request, SystemizationTodo $todo): RedirectResponse
|
||||
{
|
||||
$data = $this->validated($request);
|
||||
$data['updated_by'] = user_id();
|
||||
$this->normalizeCompletionDates($data);
|
||||
|
||||
$removeIds = collect($request->input('remove_attachments', []))
|
||||
->map(static fn (mixed $id): int => (int) $id)
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
if ($removeIds->isNotEmpty()) {
|
||||
$attachments = $todo->attachments()
|
||||
->where('company_id', (int) company_id())
|
||||
->whereIn('id', $removeIds)
|
||||
->get();
|
||||
foreach ($attachments as $attachment) {
|
||||
Storage::disk('uploads')->delete($attachment->path);
|
||||
$attachment->delete();
|
||||
}
|
||||
}
|
||||
|
||||
$todo->update($data);
|
||||
$this->storeAttachments($request, $todo);
|
||||
|
||||
return $this->backToPage('待办事项已更新。');
|
||||
}
|
||||
|
||||
public function destroy(SystemizationTodo $todo): RedirectResponse
|
||||
{
|
||||
$todo->update(['updated_by' => user_id()]);
|
||||
$todo->delete();
|
||||
|
||||
return $this->backToPage('待办事项已删除。');
|
||||
}
|
||||
|
||||
public function attachment(SystemizationTodo $todo, SystemizationTodoAttachment $attachment): StreamedResponse
|
||||
{
|
||||
abort_unless((int) $attachment->systemization_todo_id === (int) $todo->id, 404);
|
||||
abort_unless((int) $attachment->company_id === (int) company_id(), 404);
|
||||
|
||||
$disk = Storage::disk('uploads');
|
||||
abort_unless($disk->exists($attachment->path), 404, '截图不存在。');
|
||||
|
||||
return $disk->response($attachment->path, $attachment->original_name, [
|
||||
'Cache-Control' => 'private, no-store',
|
||||
'Content-Type' => $attachment->mime_type,
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
|
||||
public function importWorkbook(
|
||||
Request $request,
|
||||
SystemizationTodoWorkbookImporter $importer,
|
||||
): RedirectResponse {
|
||||
$data = $request->validate([
|
||||
'source' => ['required', 'file', 'mimes:xlsx', 'max:20480'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $importer->import(
|
||||
$data['source']->getRealPath(),
|
||||
(int) company_id(),
|
||||
user_id(),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return $this->backToPage('导入失败,请确认工作簿表头、文件格式和内容后重试。', 'error');
|
||||
}
|
||||
|
||||
return $this->backToPage(sprintf(
|
||||
'导入完成:%s,新增 %d 条,跳过已存在 %d 条。',
|
||||
$result['sheet'],
|
||||
$result['created'],
|
||||
$result['skipped'],
|
||||
));
|
||||
}
|
||||
|
||||
private function filters(): array
|
||||
{
|
||||
$status = (string) request('status', '');
|
||||
|
||||
return [
|
||||
'status' => $status === self::OVERDUE_FILTER
|
||||
|| in_array($status, SystemizationTodo::STATUSES, true)
|
||||
? $status
|
||||
: '',
|
||||
'category' => trim((string) request('category', '')),
|
||||
'owners' => $this->peopleFilter('owner'),
|
||||
'assignees' => $this->peopleFilter('assignee'),
|
||||
'start_date' => $this->normalizeDateFilter(request('start_date')),
|
||||
'due_date' => $this->normalizeDateFilter(request('due_date')),
|
||||
'keyword' => trim((string) request('keyword', '')),
|
||||
];
|
||||
}
|
||||
|
||||
private function peopleFilter(string $key): array
|
||||
{
|
||||
return collect((array) request($key, []))
|
||||
->filter(static fn (mixed $value): bool => is_string($value))
|
||||
->map(static fn (string $value): string => trim($value))
|
||||
->filter()
|
||||
->unique()
|
||||
->take(50)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function peopleOptions(string $column): Collection
|
||||
{
|
||||
return SystemizationTodo::query()
|
||||
->whereNotNull($column)
|
||||
->where($column, '<>', '')
|
||||
->pluck($column)
|
||||
->flatMap(static fn (string $value): array => array_map('trim', preg_split('/[、,,]/u', $value) ?: []))
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
}
|
||||
|
||||
private function normalizeDateFilter(mixed $value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
|
||||
return $date instanceof \DateTimeImmutable && $date->format('Y-m-d') === $value
|
||||
? $value
|
||||
: '';
|
||||
}
|
||||
|
||||
private function validated(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'category' => ['nullable', 'string', 'max:100'],
|
||||
'section' => ['nullable', 'string', 'max:255'],
|
||||
'title' => ['required', 'string', 'max:5000'],
|
||||
'owner' => ['nullable', 'string', 'max:255'],
|
||||
'assignee' => ['nullable', 'string', 'max:255'],
|
||||
'start_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'due_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
|
||||
'status' => ['required', Rule::in(SystemizationTodo::STATUSES)],
|
||||
'completed_at' => ['nullable', 'date_format:Y-m-d'],
|
||||
'progress' => ['nullable', 'string', 'max:10000'],
|
||||
'notes' => ['nullable', 'string', 'max:10000'],
|
||||
'screenshots' => ['nullable', 'array', 'max:12'],
|
||||
'screenshots.*' => ['file', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
|
||||
'remove_attachments' => ['nullable', 'array'],
|
||||
'remove_attachments.*' => ['integer'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function normalizeCompletionDates(array &$data): void
|
||||
{
|
||||
$completed = ($data['status'] ?? '') === SystemizationTodo::STATUS_COMPLETED;
|
||||
$data['completed_at'] = $completed
|
||||
? (($data['completed_at'] ?? null) ?: ($data['due_date'] ?? null) ?: now()->toDateString())
|
||||
: null;
|
||||
}
|
||||
|
||||
private function storeAttachments(Request $request, SystemizationTodo $todo): void
|
||||
{
|
||||
foreach ($request->file('screenshots', []) as $file) {
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
$fileName = (string) Str::uuid() . ($extension !== '' ? '.' . $extension : '');
|
||||
$path = Storage::disk('uploads')->putFileAs(
|
||||
'systemization-todos/' . (int) company_id() . '/' . $todo->id,
|
||||
$file,
|
||||
$fileName,
|
||||
);
|
||||
if ($path === false) {
|
||||
throw new \RuntimeException('完成截图保存失败。');
|
||||
}
|
||||
|
||||
SystemizationTodoAttachment::query()->create([
|
||||
'company_id' => (int) company_id(),
|
||||
'systemization_todo_id' => $todo->id,
|
||||
'path' => $path,
|
||||
'original_name' => mb_substr(basename($file->getClientOriginalName()), 0, 240),
|
||||
'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
|
||||
'size' => $file->getSize() ?: 0,
|
||||
'uploaded_by' => user_id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function backToPage(string $message, string $type = 'success'): RedirectResponse
|
||||
{
|
||||
return redirect()
|
||||
->route('business.pages.show', [
|
||||
'section' => 'other',
|
||||
'page' => 'systemization-todos',
|
||||
])
|
||||
->with($type === 'success' ? 'success' : 'error', $message);
|
||||
}
|
||||
}
|
||||
@ -34,6 +34,11 @@ class WithdrawalManagement extends Controller
|
||||
return $this->spreadsheet->template();
|
||||
}
|
||||
|
||||
public function withdrawalTemplate(Request $request): StreamedResponse
|
||||
{
|
||||
return $this->spreadsheet->withdrawalTemplate($this->report->results($request));
|
||||
}
|
||||
|
||||
public function importPreview(Request $request): RedirectResponse
|
||||
{
|
||||
$file = $request->validate([
|
||||
@ -93,6 +98,7 @@ class WithdrawalManagement extends Controller
|
||||
'storeEntity' => 'store_entity',
|
||||
'keyword' => 'keyword',
|
||||
'balanceStatus' => 'balance_status',
|
||||
'balanceDate' => 'balance_date',
|
||||
];
|
||||
|
||||
foreach ($queryNames as $filterName => $queryName) {
|
||||
|
||||
@ -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)];
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ namespace App\Http\Controllers\SystemSettings;
|
||||
use App\Models\Auth\Role;
|
||||
use App\Models\Auth\User;
|
||||
use App\Models\Auth\UserAccessGroup;
|
||||
use App\Utilities\AgreementSigningPermissions;
|
||||
use App\Utilities\SystemAccess;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
@ -66,6 +67,9 @@ class Users extends Controller
|
||||
|
||||
$created->roles()->attach($role->id);
|
||||
$created->companies()->attach(company_id());
|
||||
if (array_key_exists('agreement_signing_allowed', $data)) {
|
||||
AgreementSigningPermissions::setGranted($created, (bool) $data['agreement_signing_allowed']);
|
||||
}
|
||||
});
|
||||
|
||||
flash('用户已新增。')->success();
|
||||
@ -122,6 +126,9 @@ class Users extends Controller
|
||||
|
||||
$managedUser->update($attributes);
|
||||
$managedUser->syncRoles([$role->id]);
|
||||
if (array_key_exists('agreement_signing_allowed', $data)) {
|
||||
AgreementSigningPermissions::setGranted($managedUser, (bool) $data['agreement_signing_allowed']);
|
||||
}
|
||||
});
|
||||
|
||||
$managedUser->flushCache();
|
||||
@ -182,6 +189,7 @@ class Users extends Controller
|
||||
'role' => ['required', Rule::in($assignable)],
|
||||
'password' => [$managedUser ? 'nullable' : 'required', 'string', 'min:8', 'confirmed'],
|
||||
'enabled' => ['nullable', 'boolean'],
|
||||
'agreement_signing_allowed' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
48
app/Http/Controllers/SystemSettings/WorkflowReviewers.php
Normal file
48
app/Http/Controllers/SystemSettings/WorkflowReviewers.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SystemSettings;
|
||||
|
||||
use App\Support\CourierOnboardingReviewers;
|
||||
use App\Utilities\SystemAccess;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class WorkflowReviewers extends Controller
|
||||
{
|
||||
public function __construct(private readonly CourierOnboardingReviewers $reviewers)
|
||||
{
|
||||
$this->middleware(function ($request, $next) {
|
||||
abort_unless(SystemAccess::canOpenSettings(), 403);
|
||||
return $next($request);
|
||||
});
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return view('system_settings.workflow-reviewers.index', [
|
||||
'reviewers' => $this->reviewers->settings(), 'history' => $this->reviewers->history(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function search(Request $request)
|
||||
{
|
||||
$data = $request->validate(['q' => ['required', 'string', 'min:1', 'max:50']]);
|
||||
try {
|
||||
return response()->json(['people' => $this->reviewers->search(trim($data['q']))])->header('Cache-Control', 'no-store');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => '钉钉花名册暂时无法查询,请稍后重试。'], 503)->header('Cache-Control', 'no-store');
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'first' => ['required', 'array', 'min:1', 'max:10'],
|
||||
'first.*' => ['required', 'string', 'max:100', 'distinct'],
|
||||
'final' => ['required', 'array', 'min:1', 'max:10'],
|
||||
'final.*' => ['required', 'string', 'max:100', 'distinct'],
|
||||
], ['first.required' => '请至少选择一名初审人员。', 'final.required' => '请至少选择一名复审人员。']);
|
||||
$this->reviewers->save($data, user());
|
||||
return redirect()->route('system-settings.workflow-reviewers.index')->with('success', '审核人员已保存,立即对待审核申请生效。');
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,7 @@ class Kernel extends HttpKernel
|
||||
'cookies.response',
|
||||
'session.start',
|
||||
\App\Http\Middleware\LogoutIfDingTalkBindingRevoked::class,
|
||||
\App\Http\Middleware\EnsureLimitedDingTalkPageAccess::class,
|
||||
// 'session.auth',
|
||||
'session.errors',
|
||||
'csrf',
|
||||
|
||||
76
app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php
Normal file
76
app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Auth\SupplierPortalAccess;
|
||||
use App\Utilities\SidebarPermissions;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EnsureLimitedDingTalkPageAccess
|
||||
{
|
||||
public function handle(Request $request, Closure $next): mixed
|
||||
{
|
||||
$user = $request->user();
|
||||
$route = $request->route();
|
||||
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);
|
||||
}
|
||||
|
||||
// Keep the application entrance, sign-out and existing self-profile checks.
|
||||
if ($route->named('dashboard', 'logout', 'profile.edit', 'profile.update')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$allowed = SidebarPermissions::allowedKeys($user);
|
||||
$key = $route->parameter('section').'/'.$route->parameter('page');
|
||||
if (in_array($key, $allowed, true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// The section and quote group are navigation containers for the three pages.
|
||||
if ($route->named('business.pages.show') && $route->parameter('section') === 'courier-payables'
|
||||
&& in_array($route->parameter('page'), [null, 'courier-quote'], true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Dedicated detail, file and action routes declare their owning business page.
|
||||
foreach ($route->gatherMiddleware() as $middleware) {
|
||||
if (is_string($middleware) && str_starts_with($middleware, 'business.page:')
|
||||
&& in_array(str_replace(',', '/', substr($middleware, strlen('business.page:'))), $allowed, true)) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
// Also deny legacy exports/previews that do not declare a page permission.
|
||||
abort(403, '当前账号没有该页面权限。');
|
||||
}
|
||||
}
|
||||
@ -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 || $user->created_from !== 'dingtalk_roster') {
|
||||
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 . ' <原参数>。网页容器未执行本次导入。'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -62,7 +62,7 @@ class ShowInAdmin
|
||||
|
||||
public static function groupPayables(array $navigation): array
|
||||
{
|
||||
$keys = ['supplier-payables-final', 'courier-payables-final', 'supplier-payables', 'courier-payables', 'raw-board-payables',
|
||||
$keys = ['supplier-payables-final', 'supplier-reconciliation', 'courier-payables-final', 'supplier-payables', 'courier-payables', 'raw-board-payables',
|
||||
'lamination-factory-payables', 'paper-materials', 'edge-banding-materials'];
|
||||
$children = array_values(array_filter($navigation, static fn (array $section): bool => in_array($section['slug'], $keys, true)));
|
||||
if ($children === []) {
|
||||
@ -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');
|
||||
|
||||
@ -100,6 +100,32 @@ class CourierAgreementSignature extends Model
|
||||
return $partnerType === 'supplier' ? '供应商' : '快递公司';
|
||||
}
|
||||
|
||||
/** Only completed archives with a readable courier snapshot belong to contract management. */
|
||||
public function archivedCourierContractPayload(): ?array
|
||||
{
|
||||
if ($this->status !== self::STATUS_ARCHIVED || $this->document_type !== 'cooperation-contract') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->payload;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Before partner_type was introduced all signing tasks were courier tasks.
|
||||
return is_array($payload) && ($payload['partner_type'] ?? 'courier') === 'courier' ? $payload : null;
|
||||
}
|
||||
|
||||
public function credentialDocuments(): array
|
||||
{
|
||||
try {
|
||||
return $this->payload['credential_documents'] ?? [];
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public function statusLabel(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
|
||||
@ -51,6 +51,11 @@ class CourierQuoteChange extends Model
|
||||
return $this->hasOne(CourierQuoteVersion::class, 'source_change_id');
|
||||
}
|
||||
|
||||
public function stockEntry(): HasOne
|
||||
{
|
||||
return $this->hasOne(CourierStockQuoteEntry::class, 'source_change_id');
|
||||
}
|
||||
|
||||
/** 按完整报价结构提交的变价(快递入驻后的后台变价 / 重新报价)。 */
|
||||
/** 执行期间文字,如「2026-10-12 至 2026-10-15(临时)」或「2026-10-01 起」。 */
|
||||
public function periodLabel(): string
|
||||
@ -65,6 +70,11 @@ class CourierQuoteChange extends Model
|
||||
return is_array($this->quote_payload) && $this->quote_payload !== [];
|
||||
}
|
||||
|
||||
public function isStockQuote(): bool
|
||||
{
|
||||
return $this->change_type === '存量报价' && is_numeric(data_get($this->quote_payload, 'stock_quote_entry_id'));
|
||||
}
|
||||
|
||||
public function isEditable(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_DRAFT, self::STATUS_RETURNED], true);
|
||||
|
||||
@ -34,6 +34,7 @@ class CourierQuoteChangeEvent extends Model
|
||||
'returned' => '退回修改',
|
||||
'approved' => '审核通过',
|
||||
'voided' => '版本作废',
|
||||
'period_end_updated' => '设置执行截止日期',
|
||||
default => $this->event_type,
|
||||
};
|
||||
}
|
||||
|
||||
@ -12,8 +12,9 @@ class CourierQuoteVersion extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
|
||||
'carrier_name', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json',
|
||||
'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');
|
||||
|
||||
38
app/Models/Business/CourierStockQuoteEntry.php
Normal file
38
app/Models/Business/CourierStockQuoteEntry.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CourierStockQuoteEntry extends Model
|
||||
{
|
||||
protected $table = 'business_courier_stock_quote_entries';
|
||||
|
||||
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', 'effective_date', 'end_date',
|
||||
];
|
||||
|
||||
protected $casts = ['company_id' => 'integer', 'files' => 'array', 'effective_date' => 'date', 'end_date' => 'date'];
|
||||
|
||||
public function change(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CourierQuoteChange::class, 'source_change_id');
|
||||
}
|
||||
|
||||
public function sourceQuote(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'STOCK-' . $this->id,
|
||||
'courier_expcode' => $this->courier_expcode,
|
||||
'billing_rules' => data_get($this->change?->quote_payload, 'billing_rules'),
|
||||
'company_name' => $this->courier_name,
|
||||
'sheet_name' => $this->sheet_name,
|
||||
'quote_type' => $this->quote_type,
|
||||
'source_range' => $this->source_range,
|
||||
'values' => json_decode($this->values_json, true, 512, JSON_THROW_ON_ERROR),
|
||||
'formulas' => json_decode($this->formulas_json, true, 512, JSON_THROW_ON_ERROR),
|
||||
];
|
||||
}
|
||||
}
|
||||
68
app/Models/Business/SystemizationTodo.php
Normal file
68
app/Models/Business/SystemizationTodo.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class SystemizationTodo extends Model
|
||||
{
|
||||
public const STATUS_PENDING = '待办';
|
||||
|
||||
public const STATUS_IN_PROGRESS = '进行中';
|
||||
|
||||
public const STATUS_COMPLETED = '已完成';
|
||||
|
||||
public const STATUS_DELAYED = '已延期';
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_IN_PROGRESS,
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_DELAYED,
|
||||
];
|
||||
|
||||
protected $table = 'business_systemization_todos';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'category',
|
||||
'section',
|
||||
'title',
|
||||
'owner',
|
||||
'assignee',
|
||||
'start_date',
|
||||
'start_note',
|
||||
'due_date',
|
||||
'due_note',
|
||||
'status',
|
||||
'completed_at',
|
||||
'progress',
|
||||
'notes',
|
||||
'sort_order',
|
||||
'source_key',
|
||||
'imported_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'company_id' => 'integer',
|
||||
'start_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'completed_at' => 'date',
|
||||
'sort_order' => 'integer',
|
||||
'imported_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(SystemizationTodoAttachment::class, 'systemization_todo_id')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
}
|
||||
32
app/Models/Business/SystemizationTodoAttachment.php
Normal file
32
app/Models/Business/SystemizationTodoAttachment.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Business;
|
||||
|
||||
use App\Abstracts\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SystemizationTodoAttachment extends Model
|
||||
{
|
||||
protected $table = 'business_systemization_todo_attachments';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'systemization_todo_id',
|
||||
'path',
|
||||
'original_name',
|
||||
'mime_type',
|
||||
'size',
|
||||
'uploaded_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'company_id' => 'integer',
|
||||
'systemization_todo_id' => 'integer',
|
||||
'size' => 'integer',
|
||||
];
|
||||
|
||||
public function todo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SystemizationTodo::class, 'systemization_todo_id');
|
||||
}
|
||||
}
|
||||
@ -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,14 @@ class DingTalkClosedAccess
|
||||
}
|
||||
|
||||
$roster = app(FinanceRoster::class)->findEligible((string) ($profile['dingtalk_user_id'] ?? ''));
|
||||
if (! $roster) {
|
||||
$restricted = $roster !== null && ($roster['restricted'] ?? false) === true;
|
||||
$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 +54,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,
|
||||
@ -56,6 +78,9 @@ class DingTalkClosedAccess
|
||||
'nickname' => $profile['nickname'] ?: $identity->nickname,
|
||||
])->save();
|
||||
|
||||
// 按当前部门刷新访问范围:财务中心=全部,其它在职员工=风险管控两个页面。
|
||||
$this->applyAccessScope($identity->user, $restricted);
|
||||
|
||||
return $identity;
|
||||
}
|
||||
|
||||
@ -69,7 +94,7 @@ class DingTalkClosedAccess
|
||||
return null;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($profile, $roster, $request): ?DingTalkIdentity {
|
||||
return DB::transaction(function () use ($profile, $roster, $restricted, $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 +117,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,20 +130,27 @@ 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' => 'dingtalk_roster',
|
||||
'created_from' => $supplierPortal
|
||||
? SupplierPortalAccess::CREATED_FROM
|
||||
: ($restricted
|
||||
? 'dingtalk_restricted'
|
||||
: (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,
|
||||
@ -130,12 +163,35 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动开通的钉钉账号按当前部门刷新访问范围;手工账号不受影响。
|
||||
*/
|
||||
private function applyAccessScope(User $user, bool $restricted): void
|
||||
{
|
||||
$createdFrom = (string) $user->created_from;
|
||||
|
||||
if (! in_array($createdFrom, ['dingtalk_roster', 'dingtalk_restricted'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$target = $restricted ? 'dingtalk_restricted' : 'dingtalk_roster';
|
||||
|
||||
if ($createdFrom !== $target) {
|
||||
$user->forceFill(['created_from' => $target])->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function recordLogin(DingTalkIdentity $identity, Request $request): void
|
||||
{
|
||||
$this->audit('login_success', 'success', $identity->user_id, null, $identity->union_id, $request);
|
||||
@ -164,9 +220,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
|
||||
|
||||
@ -12,6 +12,11 @@ class FinanceRoster
|
||||
return $userId !== '' && in_array($userId, config('dingtalk.super_admin_user_ids', []), true);
|
||||
}
|
||||
|
||||
public static function isLimitedLoginUser(string $userId): bool
|
||||
{
|
||||
return $userId !== '' && in_array($userId, config('dingtalk.limited_login_user_ids', []), true);
|
||||
}
|
||||
|
||||
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
|
||||
{
|
||||
}
|
||||
@ -82,18 +87,34 @@ class FinanceRoster
|
||||
return null;
|
||||
}
|
||||
$allowed = array_map('strval', config('dingtalk.finance_department_ids', []));
|
||||
if (! self::isDesignatedSuperAdmin($userId)
|
||||
&& ! array_intersect($allowed, array_map('strval', array_column($ancestors, 'Id')))) {
|
||||
return null;
|
||||
}
|
||||
// 财务中心(或指定超级管理员/受限登录名单)拥有完整权限;
|
||||
// 其它在职员工返回 restricted,只能访问风险管控下的两个页面。
|
||||
$inFinance = self::isDesignatedSuperAdmin($userId)
|
||||
|| self::isLimitedLoginUser($userId)
|
||||
|| (bool) array_intersect($allowed, array_map('strval', array_column($ancestors, 'Id')));
|
||||
|
||||
return [
|
||||
'roster_id' => (string) $flower['Id'],
|
||||
'display_name' => trim((string) ($flower['flowerName'] ?: $flower['name'])),
|
||||
'department_ids' => $departmentIds,
|
||||
'restricted' => ! $inFinance,
|
||||
];
|
||||
}
|
||||
|
||||
/** Minimal, bounded directory search for workflow selection; no HR dataset is cached. */
|
||||
public function search(string $term): array
|
||||
{
|
||||
if (trim($term) === '') {
|
||||
return [];
|
||||
}
|
||||
$like = '%' . str_replace(['!', '%', '_', '['], ['!!', '!%', '!_', '!['], trim($term)) . '%';
|
||||
return $this->queries->forSource('operations_replica')->select(
|
||||
"SELECT TOP (12) flowerNameId, name, flowerName FROM rpt.hr_flowerName_book
|
||||
WHERE leaveDate IS NULL AND flowerNameId IS NOT NULL
|
||||
AND (name LIKE ? ESCAPE '!' OR flowerName LIKE ? ESCAPE '!') ORDER BY Id DESC", [$like, $like]
|
||||
);
|
||||
}
|
||||
|
||||
private function isResigned(mixed $value): bool
|
||||
{
|
||||
$status = trim((string) $value);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,18 @@ final class EsignClient
|
||||
|
||||
private readonly EsignRequestSigner $signer;
|
||||
|
||||
/**
|
||||
* 骑缝章中心的纵向坐标(PDF 坐标,原点在左下角,A4 高 841.89)。
|
||||
* 按企业签署方顺序依次取用:第一家偏上、第二家居中偏下、第三家靠下。
|
||||
* 相邻两枚间隔 200,大于骑缝章高度 120,叠页后各自完整、互不重叠。
|
||||
*/
|
||||
private const ACROSS_PAGE_SEAL_Y = [620.0, 420.0, 220.0];
|
||||
|
||||
private const ACROSS_PAGE_SEAL_SIZE = '120';
|
||||
|
||||
/** @var array<string, int> 本实例上传过的文件 ID => 页数(0 表示未能识别)。 */
|
||||
private array $pageCounts = [];
|
||||
|
||||
public function __construct(?EsignConfiguration $configuration = null)
|
||||
{
|
||||
$configuration ??= EsignConfiguration::fromConfig();
|
||||
@ -77,6 +89,7 @@ final class EsignClient
|
||||
}
|
||||
|
||||
$this->waitUntilFileReady($fileId);
|
||||
$this->pageCounts[$fileId] = self::pdfPageCount($bytes);
|
||||
|
||||
return $fileId;
|
||||
}
|
||||
@ -119,12 +132,36 @@ final class EsignClient
|
||||
],
|
||||
]],
|
||||
]];
|
||||
$signerPayload = $this->withAcrossPageSeals($fileId, $signerPayload);
|
||||
|
||||
$data = $this->request('POST', '/v3/sign-flow/create-by-file', [
|
||||
'docs' => [[
|
||||
$docs = [[
|
||||
'fileId' => $fileId,
|
||||
'fileName' => $fileName,
|
||||
]],
|
||||
]];
|
||||
foreach ($signer['additional_docs'] ?? [] as $document) {
|
||||
$index = (int) $document['signer_index'];
|
||||
if (! isset($signerPayload[$index]) || blank($document['fileId'])) {
|
||||
throw new RuntimeException('证件推送单的签署方或文件 ID 无效。');
|
||||
}
|
||||
if (($document['stamp_only'] ?? false) && $signerPayload[$index]['signerType'] !== 1) {
|
||||
throw new RuntimeException('营业执照需要企业盖章,请选择企业签署方。');
|
||||
}
|
||||
$docs[] = ['fileId' => $document['fileId'], 'fileName' => $document['fileName']];
|
||||
// Each credential PDF is one page, with exactly one field assigned
|
||||
// to the chosen recipient. Other signers only sign the main file.
|
||||
$signerPayload[$index]['signFields'][] = [
|
||||
'fileId' => $document['fileId'],
|
||||
'normalSignFieldConfig' => [
|
||||
'freeMode' => false,
|
||||
'movableSignField' => false,
|
||||
'signFieldStyle' => 1,
|
||||
'signFieldSize' => '120',
|
||||
'signFieldPosition' => ['positionPage' => '1', 'positionX' => 420.0, 'positionY' => 725.0],
|
||||
],
|
||||
];
|
||||
}
|
||||
$data = $this->request('POST', '/v3/sign-flow/create-by-file', [
|
||||
'docs' => $docs,
|
||||
'signFlowConfig' => $flowConfig,
|
||||
'signers' => $signerPayload,
|
||||
]);
|
||||
@ -137,6 +174,49 @@ final class EsignClient
|
||||
return $signFlowId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给主文件上的每个企业签署方各加一个固定位置的骑缝签署区,并按顺序错开纵向位置。
|
||||
*
|
||||
* 原来骑缝章由签署人在自由签模式下自己添加,E 签宝把各方的骑缝章放在同一默认高度,
|
||||
* 双方的章会叠在一起,看起来像盖了两遍。现在由系统预先放好,每方一个高度。
|
||||
* 个人签署方不加骑缝;主文件少于 2 页或页数识别不出时也不加。
|
||||
*/
|
||||
private function withAcrossPageSeals(string $fileId, array $signerPayload): array
|
||||
{
|
||||
if (($this->pageCounts[$fileId] ?? 0) < 2) {
|
||||
return $signerPayload;
|
||||
}
|
||||
|
||||
$orgOrder = 0;
|
||||
foreach ($signerPayload as $index => $payload) {
|
||||
if (($payload['signerType'] ?? null) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$positionY = self::ACROSS_PAGE_SEAL_Y[$orgOrder++] ?? null;
|
||||
if ($positionY === null) {
|
||||
break;
|
||||
}
|
||||
$signerPayload[$index]['signFields'][] = [
|
||||
'fileId' => $fileId,
|
||||
'normalSignFieldConfig' => [
|
||||
'freeMode' => false,
|
||||
'movableSignField' => false,
|
||||
'signFieldStyle' => 2,
|
||||
'signFieldSize' => self::ACROSS_PAGE_SEAL_SIZE,
|
||||
'signFieldPosition' => ['acrossPageMode' => 'ALL', 'positionY' => $positionY],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $signerPayload;
|
||||
}
|
||||
|
||||
/** 统计 PDF 页数:数 /Type /Page 对象(不含 /Pages)。系统生成的 dompdf 文件可直接识别;识别不出返回 0。 */
|
||||
private static function pdfPageCount(string $bytes): int
|
||||
{
|
||||
return (int) preg_match_all('#/Type\s*/Page(?![a-zA-Z])#', $bytes);
|
||||
}
|
||||
|
||||
private function signerPayload(string $fileId, array $signer, int $index): array
|
||||
{
|
||||
$type = (int) ($signer['signer_type'] ?? 1);
|
||||
|
||||
105
app/Support/CourierAgreementCredentials.php
Normal file
105
app/Support/CourierAgreementCredentials.php
Normal file
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Business\CourierAgreementSignature;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Intervention\Image\ImageManager;
|
||||
use RuntimeException;
|
||||
|
||||
final class CourierAgreementCredentials
|
||||
{
|
||||
public const IMAGES = [
|
||||
'license' => '营业执照', 'id_front' => '身份证人像面', 'id_back' => '身份证国徽面',
|
||||
'bank_front' => '银行卡正面', 'bank_back' => '银行卡背面',
|
||||
];
|
||||
|
||||
public static function definitions(string $type): array
|
||||
{
|
||||
$sheets = [
|
||||
'identity' => ['name' => '身份证推送单', 'images' => ['id_front', 'id_back']],
|
||||
'bank' => ['name' => '银行卡推送单', 'images' => ['bank_front', 'bank_back']],
|
||||
];
|
||||
return match ($type) {
|
||||
'authorization' => $sheets,
|
||||
'entity-change' => ['license' => ['name' => '营业执照盖章推送单', 'images' => ['license']]] + $sheets,
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
public static function rules(string $type, bool $requested): array
|
||||
{
|
||||
$allowed = self::definitions($type);
|
||||
$required = $requested ? array_merge(...array_column($allowed ?: [['images' => []]], 'images')) : [];
|
||||
$rules = [
|
||||
'credential_required' => ['nullable', 'boolean', Rule::prohibitedIf($allowed === [] && $requested)],
|
||||
'credential_signer' => ['nullable', 'integer', 'in:0,1'],
|
||||
'credential_images' => ['nullable', 'array:' . implode(',', array_keys(self::IMAGES))],
|
||||
];
|
||||
foreach (self::IMAGES as $key => $label) {
|
||||
$active = in_array($key, $required, true);
|
||||
$rules['credential_images.' . $key] = [Rule::requiredIf($active), Rule::prohibitedIf(! $active),
|
||||
'nullable', 'image', 'mimes:jpg,jpeg,png', 'max:10240', 'dimensions:max_width=5000,max_height=5000'];
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function prepare(CourierAgreementSignature $signature, array $uploads): array
|
||||
{
|
||||
$definitions = self::definitions($signature->document_type);
|
||||
if (! ($signature->payload['credential_required'] ?? false)) {
|
||||
return [];
|
||||
}
|
||||
$disk = Storage::disk('uploads');
|
||||
$base = "courier-agreements/{$signature->company_id}/{$signature->business_no}/credentials";
|
||||
$result = [];
|
||||
foreach ($definitions as $key => $definition) {
|
||||
$pictures = [];
|
||||
$originals = [];
|
||||
foreach ($definition['images'] as $imageKey) {
|
||||
$upload = $uploads[$imageKey] ?? null;
|
||||
if (! $upload instanceof UploadedFile || ! $upload->isValid()) {
|
||||
throw new RuntimeException('证件图片不完整,请重新上传。');
|
||||
}
|
||||
$extension = $upload->getMimeType() === 'image/png' ? 'png' : 'jpg';
|
||||
$originalPath = "{$base}/{$imageKey}.{$extension}";
|
||||
$original = file_get_contents($upload->getRealPath());
|
||||
if (! $disk->put($originalPath, $original)) {
|
||||
throw new RuntimeException('证件原始图片无法写入私有存储。');
|
||||
}
|
||||
$image = (new ImageManager(['driver' => 'gd']))->make($upload->getRealPath())->orientate();
|
||||
$image->resize(2400, 2400, static function ($constraint): void {
|
||||
$constraint->aspectRatio();
|
||||
$constraint->upsize();
|
||||
});
|
||||
$maxHeight = $key === 'license' ? 470 : 180;
|
||||
$scale = min(450 / $image->width(), $maxHeight / $image->height());
|
||||
$pictures[] = ['label' => self::IMAGES[$imageKey],
|
||||
'uri' => 'data:image/png;base64,' . base64_encode((string) $image->encode('png')),
|
||||
'width' => round($image->width() * $scale, 2), 'height' => round($image->height() * $scale, 2)];
|
||||
$originals[$imageKey] = ['path' => $originalPath, 'sha256' => hash('sha256', $original)];
|
||||
$image->destroy();
|
||||
}
|
||||
$pdf = app('dompdf.wrapper');
|
||||
$pdf->setPaper('a4', 'portrait');
|
||||
$pdf->loadHTML(view('business.pdf.courier-credential', [
|
||||
'title' => $definition['name'], 'pictures' => $pictures,
|
||||
'stampOnly' => $key === 'license', 'signature' => $signature,
|
||||
'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')),
|
||||
])->render(), 'UTF-8');
|
||||
$bytes = $pdf->output();
|
||||
if (! str_starts_with($bytes, '%PDF-') || $pdf->getDomPDF()->getCanvas()->get_page_count() !== 1) {
|
||||
throw new RuntimeException('证件推送单必须为一页 PDF,请检查上传图片。');
|
||||
}
|
||||
$sourcePath = "{$base}/{$key}-source.pdf";
|
||||
if (! $disk->put($sourcePath, $bytes)) {
|
||||
throw new RuntimeException('证件推送单无法写入私有存储。');
|
||||
}
|
||||
$result[$key] = ['name' => $definition['name'], 'source_path' => $sourcePath,
|
||||
'source_sha256' => hash('sha256', $bytes), 'originals' => $originals];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
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