Compare commits

..

52 Commits

Author SHA1 Message Date
edc27345ee 商品利润镜像:订单时间按字符串日期解析后再筛选排序 (#449) 2026-10-21 22:22:51 +08:00
9976ca6b0a 商品利润镜像:字段映射只保留原表实际存在的列 (#448) 2026-10-21 22:18:52 +08:00
ec3cd39413 feat(财务台账): 增加可筛选排序的表头 (#447) 2026-10-21 21:13:49 +08:00
9ab4e1e261 feat(财务台账): 移入经营报表快捷分组 (#446) 2026-10-21 20:50:24 +08:00
9028f6aff4 快递应付:改用上一页、下一页分页按钮 (#445) 2026-10-21 20:07:50 +08:00
cc0cdbeed7 财务台账:新增不保留账户处理待办 (#444) 2026-10-21 17:59:29 +08:00
66c5864455 修复售后退货退款来源遗漏与历史明细校准 (#443) 2026-10-21 17:45:37 +08:00
873d306277 财务台账:补齐账户后4位并统一支付宝占位 2026-10-21 17:37:25 +08:00
04034948e3 财务台账:限额列内容居中 2026-10-21 17:13:10 +08:00
ea1bff1d5a 财务台账:金额按万元展示并增加余额筛选 2026-10-21 17:03:42 +08:00
c2a11b3064 财务台账:增加当前余额并按账户状态同步金额 2026-10-21 16:47:16 +08:00
a6f4ca8ccc feat: 接入供应商钉钉门户权限 2026-10-21 16:24:15 +08:00
0942f31d7a 财务台账:修复账户异常待办列宽恢复基线 2026-10-21 16:21:29 +08:00
18297f1845 财务台账:新增账户异常待办工作台与汇总卡片优化 (#434)
新增账户异常独立待办、筛选排序、完成状态联动、吸顶表头、列宽调整和无刷新分页,并优化汇总卡片。
2026-10-21 16:11:55 +08:00
9a7ecf879c 财务台账:支持自定义账户总表列宽 (#433)
支持拖动账户总表表头调整列宽,并按用户保存列宽偏好。
2026-10-21 14:57:36 +08:00
0ab30d5bb9 商品成本:订单侧排除状态为 Cancelled 的明细行 (#432) 2026-10-21 14:54:36 +08:00
eaeae97752 财务台账:公司规划与保留状态增加下拉编辑 (#431) 2026-10-21 14:37:41 +08:00
36af0e211a 修复生产内存保护:网页限额与重型导入隔离 (#430)
网页设置 2GiB 内存边界;生产重型导入统一进入 1CPU/1GiB 独立容器,保留只读文件、互斥、超时清理和发布保护。相关回归与资源隔离测试通过。
2026-10-21 14:30:06 +08:00
639ff3d0b3 财务台账:允许管理员编辑台账 (#429) 2026-10-21 14:29:03 +08:00
023342c6a8 财务台账:增加编辑模式开关 (#428) 2026-10-21 14:14:47 +08:00
de770efce7 商品利润镜像:超宽表非键列存 TEXT,并在首次全量完成前不对外使用 (#427) 2026-10-21 14:08:22 +08:00
20c11719e0 docs: 增加性能与资源验收强制标准 2026-10-21 14:06:49 +08:00
a1f921be2f 订单售后费用明细:改读聚水潭商品利润原表 jst_goods_profit_kpi_items_afs (#425) 2026-10-21 14:03:18 +08:00
1a7a647907 订单售后费用导入:只有完整覆盖所选日期时才使用导入数据 (#424) 2026-10-21 13:53:38 +08:00
0e0ab438c0 订单售后费用导入:按整行内容生成行键 (#423) 2026-10-21 13:50:40 +08:00
8ba241a868 订单售后费用导入:改为流式解析 xlsx,避免整本加载拖垮服务器 (#422) 2026-10-21 13:47:01 +08:00
e829ab2094 订单售后费用明细:接入聚水潭「订单售后费用数据」导入明细 (#421) 2026-10-21 13:03:24 +08:00
2186e9085a 财务台账:增加账户是否保留列 (#420) 2026-10-21 11:59:04 +08:00
a57a8116bf feat(财务台账): 移入财务经营报表并兼容旧链接
财务台账移入财务经营报表,兼容旧地址和旧权限键。
2026-10-21 11:39:31 +08:00
0b2bd343c5 fix: 恢复可用的 Composer 基础镜像源 2026-10-21 11:37:46 +08:00
345790175b 部署:切换可用的 Composer 基础镜像源 (#417) 2026-10-21 11:30:11 +08:00
36b8b5c616 财务台账:修复页签筛选提交丢失条件 (#416) 2026-10-21 11:26:45 +08:00
176357871b feat: 供应商管理新增是否包邮厂家
在供应商名称后新增「是否包邮厂家」,支持行内编辑、模板导入和导出;空白按不是包邮厂家处理。
2026-10-21 11:20:58 +08:00
6feea82305 商品成本:订单侧排除三个仓,常州简尚保留 DF- 商品行 (#414) 2026-10-21 11:14:49 +08:00
cd25916d9d fix: 补齐 Windows 发布 Composer 镜像源 (#413)
Windows 与 macOS 发布脚本统一支持 Composer 镜像源。
2026-10-21 11:12:01 +08:00
20dc695549 feat: 完善财务台账账户维护与分页体验 (#412)
合并账户维护、筛选、列设置、无刷新分页、录入模板及批量映射工具。
2026-10-21 11:00:56 +08:00
93a27725b6 feat(财务台账): 接入 AI 轻量视图并改为分批增量快照 (#411)
本地验证:7 天和 30 天增量合并后,12,037 笔明细、收入 184,308,335.23、支出 155,917,513.13 保持不变。
2026-10-21 10:37:59 +08:00
5633ed2430 安全整合存量报价计费规则与独立成本价观测 (#410) 2026-10-20 19:08:47 +08:00
d68548646d 修复多人发布并发覆盖及旧客户端绕过保护 (#409) 2026-10-20 18:13:01 +08:00
chenxi
0ec6f703fe 商品成本:移除两个筛选复选框,默认全部数据展示
- 去掉「只看未匹配商品单价」「包含-不看仓」两个复选框
- 页面与导出默认展示全部行:已匹配/未匹配单价、含「-不看」仓全部纳入
- 汇总卡片按全部口径统计,移除「已隐藏的-不看仓行数」
2026-10-20 17:46:34 +08:00
a317ab9dea 供应商对账:比对汇总改按我方商品编码匹配 (#408)
对照我方货款对账的商品编码匹配厂家条码,页面、CSV导出和钉钉通知统一切换。
2026-10-20 17:45:55 +08:00
db08b2c264 供应商对账:新增财务比对汇总导出 (#407)
财务端比对明细新增CSV导出,包含筛选汇总和完整比对结果。
2026-10-20 17:29:46 +08:00
chenxi
32cd0d0a15 商品成本:常州简尚家居有限公司的仓库简称对应映射表「配件单仓」行
业务确认:仓储方=常州简尚家居有限公司 / 仓库=配件单仓 → 简称=配件单仓-不看。
仓库简称匹配增加这一档:主仓行 → 同名仓库 → 配件单仓行(仅在没有前两者时使用)。
2026-10-20 17:15:49 +08:00
8162afa969 供应商对账:财务比对筛选与分页优化 (#406)
财务端增加供应商精确搜索、比对明细吸顶表头和每页15条分页。
2026-10-20 17:03:27 +08:00
chenxi
bcb29aac1c 商品成本:仓库简称只认映射表里「仓储方 + 主仓」那条
发货仓库是聚水潭仓储方级别的名称,映射表里同一仓储方下还有次品仓/销退仓/配件单仓等行,
之前无主仓行时会兜底取到这些行的简称(如常州简尚家居有限公司→退货报废-不看),
把主仓发货误判成不看仓。改为:仓储方匹配仓库名含「主仓」的行的简称;
没有主仓行时只接受「仓库名=仓储方名」的同名仓库,不再兜底次品/销退行。
2026-10-20 15:19:32 +08:00
d1b751c79c 业绩明细:补齐拼多多退款支付立减备注并延长联动缓存 2026-10-20 15:14:55 +08:00
chenxi
fce31f0c09 商品成本:种菜金额有数据的账单行整条排除
账单侧按业绩明细取数时,除「收入金额有数据且不为 0」外,再排除 planting_amount > 0 的账单行
(种菜订单整条不参与商品成本匹配),页面口径说明同步补充。
2026-10-20 14:12:19 +08:00
acad1f4577 修复业绩明细日期前缀串单并增加防碰撞匹配 2026-10-20 14:01:26 +08:00
ad8da1264d 供应商对账:按采购单号日期条码核对数量金额 (#405)
厂家账单改为按采购单号+日期+条码匹配,逐项比较双方数量、单价和金额,页面与钉钉通知明确展示厂家多算或少算。
2026-10-20 13:44:00 +08:00
6470346392 修复账单覆盖校验日期边界 2026-10-20 13:26:20 +08:00
f08c600961 合并京东钱包每日账单同步修复 2026-10-20 13:19:37 +08:00
chenxi
5fe92bd05c 商品成本:订单侧不取套餐/服务占位编码 TCXJ、FUWU
订单原始数据里的 TCXJ(套餐)、FUWU(服务)不是实物商品,商品成本同步时直接剔除
(SQL 层过滤 + 组合装拆出的组件里同样剔除),页面口径说明同步补充。
2026-10-20 11:53:03 +08:00
147 changed files with 13369 additions and 588 deletions

View File

@ -10,6 +10,7 @@ runtime
*.md
JetBrainsMono-字体
node_modules
**/__pycache__
vendor
bootstrap/cache/*
.caiwu-build

1
.gitignore vendored
View File

@ -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

View File

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

View File

@ -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);
}
}

View 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')));
}
}

View File

@ -11,17 +11,28 @@ 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'),
$this->option('from') ?: null,
$from,
$this->option('to') ?: null,
$merge,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());

View File

@ -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);

View File

@ -23,9 +23,9 @@ use Throwable;
*
* 口径(已与业务确认):
* - 账单侧:按「总账备注」筛选(与店铺收款账户账单页同一口径),
* 再按「店铺 + 清洗后订单号」汇总收入金额与收款笔数
* 再按「店铺 + 完整线上单号」汇总收入金额与收款笔数
* - 订单侧:销售全量按「店铺 + 线上订单号」先聚合求和(一单多品/多子订单在此合并),金额取 goods_count > 0 的有效行
* - 单号清洗:去除日期前缀(如 260630-)、空格、横杠后按核心号匹配
* - 单号匹配:优先完整线上单号精确匹配;仅当核心号唯一时允许兼容回退,多个完整单号共用核心号时禁止合并
* - 店铺:账单用「线上真实店铺」优先,订单侧用销售全量店铺名,统一把下划线归一为横杠
* - 同一店铺同一订单号多笔收款 → 标注「同单多笔收款」,商品金额只挂一次
*/
@ -448,15 +448,17 @@ class SyncStoreReceiptOrderMatches extends Command
}
}
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 核心单号」聚合的商品金额(商品金额求和) */
/** @var array<string,float> $orderAmount 订单侧按「店铺 + 完整线上单号」聚合的商品金额(商品金额求和) */
$orderAmount = [];
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 核心单号」(含 0 元行) */
/** @var array<string,true> $orderSeen 订单侧出现过的「店铺 + 完整线上单号」(含 0 元行) */
$orderSeen = [];
/** @var array<string,array<string,true>> $coresByCore 核心单号 → 出现过的店铺集合 */
$coresByCore = [];
/** @var array<string,array<string,array<string,true>>> $identitiesByCore 核心单号 → 店铺 → 完整线上单号 */
$identitiesByCore = [];
/** @var array<string,true> $coreSeen 订单侧出现过的核心单号(含店铺名为空的行) */
$coreSeen = [];
/** @var array<string,float> $orderVirtualPaid 虚拟条码(X0000x)明细行的实付金额合计(店铺 + 核心单号) */
/** @var array<string,float> $orderVirtualPaid 虚拟条码(X0000x)明细行的实付金额合计(店铺 + 完整线上单号) */
$orderVirtualPaid = [];
// 订单侧改用「订单原始数据」(企微客户订单 + 聚水潭订单),按线上订单号取商品金额合计。
// 两个来源数据量大(企微 500 万单 / 690 万行),先用临时表收敛候选单号,
@ -575,19 +577,21 @@ class SyncStoreReceiptOrderMatches extends Command
}
$this->info('订单侧命中(店铺 × 订单)行数:' . count($orderRows) . ',实付行数:' . count($paidRows));
/** @var array<string,float> $orderPaid 订单侧实付金额(店铺 + 核心单号) */
/** @var array<string,float> $orderPaid 订单侧实付金额(店铺 + 完整线上单号) */
$orderPaid = [];
/** @var array<string,float> $orderPaidAlt 订单侧「卖家实收」金额(双口径兜底用) */
$orderPaidAlt = [];
/** @var array<string,float> $orderPaidPay 订单侧「应付」金额(实收缺失时的退化口径) */
$orderPaidPay = [];
foreach ($paidRows as $row) {
$core = self::cleanOrderNo((string) $row['so_id']);
$rawNo = (string) $row['so_id'];
$core = self::cleanOrderNo($rawNo);
$identity = self::orderIdentity($rawNo);
if ($core === '') {
continue;
}
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
$key = $shopKey . '|' . $core;
$key = $shopKey . '|' . $identity;
$orderPaid[$key] = ($orderPaid[$key] ?? 0.0) + (float) $row['paid'];
$orderPaidAlt[$key] = ($orderPaidAlt[$key] ?? 0.0) + (float) ($row['paid_alt'] ?? 0.0);
$orderPaidPay[$key] = ($orderPaidPay[$key] ?? 0.0) + (float) ($row['paid_pay'] ?? 0.0);
@ -596,6 +600,7 @@ class SyncStoreReceiptOrderMatches extends Command
// 商品金额数组仍只由商品明细构建:无明细时商品金额保持为空、按口径标「订单无商品金额」。
$orderSeen[$key] = true;
$coreSeen[$core] = true;
$identitiesByCore[$core][$shopKey][$identity] = true;
if ($shopKey !== '') {
$coresByCore[$core][$shopKey] = true;
}
@ -603,14 +608,17 @@ class SyncStoreReceiptOrderMatches extends Command
unset($paidRows, $row);
foreach ($orderRows as $row) {
$core = self::cleanOrderNo((string) $row['so_id']);
$rawNo = (string) $row['so_id'];
$core = self::cleanOrderNo($rawNo);
$identity = self::orderIdentity($rawNo);
if ($core === '') {
continue;
}
$shopKey = self::normalizeShop((string) ($row['shop_name'] ?? ''));
$key = $shopKey . '|' . $core;
$key = $shopKey . '|' . $identity;
$orderSeen[$key] = true;
$coreSeen[$core] = true;
$identitiesByCore[$core][$shopKey][$identity] = true;
if ($shopKey !== '') {
$coresByCore[$core][$shopKey] = true;
}
@ -665,21 +673,25 @@ class SyncStoreReceiptOrderMatches extends Command
// 两个口径各跑一遍同一条取数链(同店同号 → 店铺名不一致 → 同号多条 → 空店铺兜底):
// 商品金额用于「商品金额」列,订单实付用于「订单实付金额」列与比对基准。
[$productStatus, $productAmount, $productRemark] = $this->matchOrderSide(
(string) $group['online_order_no'],
$core,
(string) $group['shop_key'],
$orderAmount,
$orderSeen,
$coreSeen,
$coresByCore,
$identitiesByCore,
self::REMARK_NO_AMOUNT,
);
[$paidStatus, $paidAmount, $paidRemark] = $this->matchOrderSide(
(string) $group['online_order_no'],
$core,
(string) $group['shop_key'],
$orderPaidNet,
$orderSeen,
$coreSeen,
$coresByCore,
$identitiesByCore,
self::REMARK_NO_PAID,
);
@ -706,12 +718,14 @@ class SyncStoreReceiptOrderMatches extends Command
// 「先备注规则、后口径兜底」的顺序判定),避免把有明确原因的行悄悄抹平。
{
[$altStatus, $altAmount] = $this->matchOrderSide(
(string) $group['online_order_no'],
$core,
(string) $group['shop_key'],
$orderPaidAlt,
$orderSeen,
$coreSeen,
$coresByCore,
$identitiesByCore,
self::REMARK_NO_PAID,
);
$altMatched = $altStatus === self::STATUS_MATCHED && $altAmount !== null;
@ -722,12 +736,14 @@ class SyncStoreReceiptOrderMatches extends Command
// 或主单没写实收而子单写了值的情况都覆盖到)。
if (! ($altMatched && abs($income + $promoAmount - $altValue) <= 0.02)) {
[$payStatus, $payValue] = $this->matchOrderSide(
(string) $group['online_order_no'],
$core,
(string) $group['shop_key'],
$orderPaidPay,
$orderSeen,
$coreSeen,
$coresByCore,
$identitiesByCore,
self::REMARK_NO_PAID,
);
if ($payStatus === self::STATUS_MATCHED && $payValue !== null) {
@ -764,7 +780,12 @@ class SyncStoreReceiptOrderMatches extends Command
$buffer[] = [
'report_month' => $reportMonth,
'platform' => $group['platform'],
'row_key' => md5($reportMonth . '|' . $group['platform'] . '|' . $group['shop_key'] . '|' . $core),
'row_key' => self::groupRowKey(
$reportMonth,
(string) $group['platform'],
(string) $group['shop_key'],
(string) $group['order_identity']
),
'occurred_date' => $group['occurred_date'],
'store_name' => $group['store_name'],
'online_order_no' => $group['online_order_no'] !== '' ? $group['online_order_no'] : null,
@ -1021,7 +1042,12 @@ class SyncStoreReceiptOrderMatches extends Command
continue;
}
$rowKey = md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|' . $group['order_core_no']);
$rowKey = self::groupRowKey(
$reportMonth,
$platform,
(string) $group['shop_key'],
(string) $group['order_identity']
);
if (! isset($existing[$rowKey])) {
continue;
}
@ -1204,9 +1230,10 @@ class SyncStoreReceiptOrderMatches extends Command
$rawNo = trim((string) ($bill->{$orderColumn} ?? ''));
$core = self::cleanOrderNo($rawNo);
$identity = self::orderIdentity($rawNo);
$realStore = $realStoreColumn !== '' ? (string) ($bill->{$realStoreColumn} ?? '') : '';
$shopKey = self::normalizeShop($realStore !== '' ? $realStore : (string) $bill->store_name);
$key = $shopKey . '|' . $core;
$key = $shopKey . '|' . $identity;
if (! isset($groups[$key])) {
$groups[$key] = [
@ -1216,6 +1243,7 @@ class SyncStoreReceiptOrderMatches extends Command
'shop_key' => $shopKey,
'online_order_no' => $rawNo,
'order_core_no' => $core,
'order_identity' => $identity,
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
'payment_count' => 0,
'income_amount' => 0.0,
@ -1235,13 +1263,14 @@ class SyncStoreReceiptOrderMatches extends Command
// 同一遍读取同时累计补行,避免把整月原始账单留到第二遍。
// 首笔元数据、无单号按账单项目拆分、收入行随后出现的语义与原有补行一致。
if ($bucket !== 'income') {
$promoKey = $core === '' ? $shopKey . '||' . $billItem : $key;
$promoKey = $identity === '' ? $shopKey . '||' . $billItem : $key;
if (! isset($promoOnlyGroups[$promoKey])) {
$promoOnlyGroups[$promoKey] = [
'shop_key' => $shopKey,
'store_name' => $realStore !== '' ? $realStore : $storeName,
'online_order_no' => $rawNo,
'order_core_no' => $core,
'order_identity' => $identity,
'bill_item' => $billItem,
'occurred_date' => substr((string) $bill->{$timeColumn}, 0, 10),
'discount_amount' => 0.0,
@ -1308,7 +1337,7 @@ class SyncStoreReceiptOrderMatches extends Command
}
// A 方案:挂不到订单行的优惠/冲减也补成行(收入/商品/差额留空),金额不丢。
foreach ($promoOnlyGroups as $key => $group) {
if ($group['order_core_no'] !== '' && isset($groups[$group['shop_key'] . '|' . $group['order_core_no']])) {
if ($group['order_identity'] !== '' && isset($groups[$group['shop_key'] . '|' . $group['order_identity']])) {
unset($promoOnlyGroups[$key]);
}
}
@ -1327,9 +1356,10 @@ class SyncStoreReceiptOrderMatches extends Command
*/
private static function promoOnlyRow(string $reportMonth, string $platform, array $group, mixed $now): array
{
$identity = (string) ($group['order_identity'] ?? '');
$core = (string) $group['order_core_no'];
$rowKey = $core !== ''
? md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|' . $core)
$rowKey = $identity !== ''
? self::groupRowKey($reportMonth, $platform, (string) $group['shop_key'], $identity)
: md5($reportMonth . '|' . $platform . '|' . $group['shop_key'] . '|promo|' . $group['bill_item']);
return [
@ -1403,6 +1433,31 @@ class SyncStoreReceiptOrderMatches extends Command
return self::orderCoreNo($value);
}
/**
* 完整线上单号身份:保留日期前缀,只统一空白、横杠形态和大小写。
*
* 拼多多的「260627-...」「260713-...」是两个不同订单,不能按去掉
* 日期前缀后的核心号合并;完整单号用于账单分组与订单侧精确匹配,
* 核心号只在唯一候选时作为兼容回退。
*/
private static function orderIdentity(string $value): string
{
$value = str_replace("\u{3000}", ' ', trim($value));
if ($value === '') {
return '';
}
$value = preg_replace('/[\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '-', $value) ?? $value;
$value = preg_replace('/\s+/u', '', $value) ?? '';
return strtoupper($value);
}
private static function groupRowKey(string $reportMonth, string $platform, string $shopKey, string $identity): string
{
return md5($reportMonth . '|' . $platform . '|' . $shopKey . '|' . $identity);
}
/**
* 种菜订单号集合:种菜明细表的订单号 ∪ 聚水潭订单标签明细里「是否种菜订单=是」的订单号。
*
@ -1558,11 +1613,13 @@ class SyncStoreReceiptOrderMatches extends Command
}
/**
* 按「店铺 + 订单号」在订单侧取金额,返回 [匹配状态, 订单侧金额, 原因标注]。
* 按「店铺 + 完整线上单号」在订单侧取金额,返回 [匹配状态, 订单侧金额, 原因标注]。
*
* 两个比对基准共用这一条取数链:
* 同店同号 → 店铺名不一致(订单号唯一)→ 同号多条(多店铺合计)→ 空店铺兜底 → 订单表无此单。
* 同店完整单号 → 同店核心号唯一时回退 → 跨店唯一时回退 → 空店铺唯一时回退。
* 核心号一旦对应多个完整线上单号就视为歧义,不再合并金额,避免不同订单串单。
*
* @param array<string,array<string,array<string,true>>> $identitiesByCore 核心单号 → 店铺 → 完整线上单号
* @param array<string,float> $amounts 订单侧金额集合(商品金额或订单实付)
* @param array<string,bool> $orderSeen 订单侧出现过的「店铺|单号」
* @param array<string,bool> $coreSeen 订单侧出现过的单号
@ -1570,12 +1627,14 @@ class SyncStoreReceiptOrderMatches extends Command
* @return array{0:string,1:?float,2:string}
*/
private function matchOrderSide(
string $onlineOrderNo,
string $core,
string $shopKey,
array $amounts,
array $orderSeen,
array $coreSeen,
array $coresByCore,
array $identitiesByCore,
string $noAmountRemark,
): array {
if ($core === '') {
@ -1586,8 +1645,9 @@ class SyncStoreReceiptOrderMatches extends Command
return [self::STATUS_UNMATCHED, null, self::REMARK_BAD_FORMAT];
}
$exactKey = $shopKey . '|' . $core;
if (isset($orderSeen[$exactKey])) {
$identity = self::orderIdentity($onlineOrderNo);
$exactKey = $shopKey . '|' . $identity;
if ($identity !== '' && isset($orderSeen[$exactKey])) {
if (isset($amounts[$exactKey])) {
$remark = count($coresByCore[$core] ?? []) > 1 ? self::REMARK_DUP_ORDER : '';
@ -1598,41 +1658,43 @@ class SyncStoreReceiptOrderMatches extends Command
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
if (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) === 1) {
// 单号一致但店铺名称对不上:仍然带上金额,标注「店铺不一致」便于复核。
$otherKey = array_key_first($coresByCore[$core]) . '|' . $core;
if (isset($amounts[$otherKey])) {
return [self::STATUS_MATCHED, round((float) $amounts[$otherKey], 2), self::REMARK_SHOP_DIFF];
if (isset($coreSeen[$core])) {
$sameShopIdentities = array_keys($identitiesByCore[$core][$shopKey] ?? []);
if (count($sameShopIdentities) > 1) {
return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER];
}
if (count($sameShopIdentities) === 1) {
$fallbackKey = $shopKey . '|' . $sameShopIdentities[0];
if (isset($amounts[$fallbackKey])) {
$remark = count($coresByCore[$core] ?? []) > 1 ? self::REMARK_DUP_ORDER : '';
return [self::STATUS_MATCHED, round((float) $amounts[$fallbackKey], 2), $remark];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
$shops = array_keys($identitiesByCore[$core] ?? []);
if (count($shops) === 1) {
$otherShop = (string) $shops[0];
$otherIdentities = array_keys($identitiesByCore[$core][$otherShop] ?? []);
if (count($otherIdentities) > 1) {
return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER];
}
if (count($otherIdentities) === 1) {
$otherKey = $otherShop . '|' . $otherIdentities[0];
if (isset($amounts[$otherKey])) {
$remark = $otherShop === '' ? '' : self::REMARK_SHOP_DIFF;
if (isset($coreSeen[$core]) && count($coresByCore[$core] ?? []) > 1) {
$sum = 0.0;
$hasAmount = false;
foreach (array_keys($coresByCore[$core]) as $otherShop) {
$key2 = $otherShop . '|' . $core;
if (isset($amounts[$key2])) {
$sum += (float) $amounts[$key2];
$hasAmount = true;
return [self::STATUS_MATCHED, round((float) $amounts[$otherKey], 2), $remark];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
}
if ($hasAmount) {
return [self::STATUS_MATCHED, round($sum, 2), self::REMARK_DUP_ORDER];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
}
if (isset($coreSeen[$core])) {
// 订单侧只有店铺名为空的记录:按订单号兜底匹配。
$blankKey = '|' . $core;
if (isset($amounts[$blankKey])) {
return [self::STATUS_MATCHED, round((float) $amounts[$blankKey], 2), ''];
}
return [self::STATUS_UNMATCHED, null, $noAmountRemark];
// 同一核心号跨多个店铺或同一店铺多个完整单号:禁止把金额相加。
return [self::STATUS_UNMATCHED, null, self::REMARK_DUP_ORDER];
}
return [self::STATUS_UNMATCHED, null, self::REMARK_NO_ORDER];

View File

@ -34,6 +34,19 @@ class SyncStoreReceiptProductCost extends Command
/** 虚拟条码(信誉订单/虚拟订单)不是真实商品,不计入商品成本。 */
private const VIRTUAL_SKU_PREFIX = 'X0000';
/** 套餐 / 服务占位编码:不是实物商品,商品成本不取数。 */
private const EXCLUDED_SKUS = ['TCXJ', 'FUWU'];
/**
* 发货仓库黑名单:这些仓不是实际销售发货仓,整仓不参与商品成本;
* 例外:常州简尚家居有限公司里商品编码以 DF- 开头的行保留。
*/
private const EXCLUDED_WAREHOUSES = [
'常州简尚家居有限公司',
'简尚-苏州福利品仓',
'简尚-自动发货仓',
];
private const REMARK_NO_WAREHOUSE = '未匹配仓库简称';
private const REMARK_NO_PRICE = '未匹配商品单价';
@ -117,10 +130,15 @@ class SyncStoreReceiptProductCost extends Command
// 账单侧取数:业绩明细里本月(可按平台限定)的账单行,一行 = 一个店铺的一个订单。
// 只取「收入金额有数据且不为 0」的账单行:收入为 0 的行是补出来的优惠/冲减/无收款账单行,
// 没有对应的收款商品,不参与商品成本。
// 「种菜金额」有数据(>0)的账单行整条排除,不参与匹配。
$billQuery = $mirror->table(self::SOURCE_TABLE)
->where('report_month', $reportMonth)
->whereNotNull('income_amount')
->where('income_amount', '!=', 0);
->where('income_amount', '!=', 0)
->where(static function ($query): void {
$query->whereNull('planting_amount')
->orWhere('planting_amount', '<=', 0);
});
if ($platform !== '') {
$billQuery->where('platform', $platform);
}
@ -171,6 +189,7 @@ class SyncStoreReceiptProductCost extends Command
/** @var array<string,list<array{sku:string,qty:float,amount:float,warehouse:string,is_main:bool,row_key:string}>> $lines */
$lines = [];
$excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'";
foreach (self::SOURCES as $source) {
try {
$rows = $mirror->select(
@ -184,7 +203,9 @@ class SyncStoreReceiptProductCost extends Command
STRAIGHT_JOIN {$source['orders']} o ON o.so_id = t.no
STRAIGHT_JOIN {$source['items']} i ON i.o_id = o.o_id
LEFT JOIN ops_jst_warehouse_info wh ON wh.wms_co_id = o.wms_co_id
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'"
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'
AND UPPER(TRIM(COALESCE(i.sku_id, ''))) NOT IN ({$excludedSkuSql})
AND UPPER(TRIM(COALESCE(o.status, ''))) <> 'CANCELLED'"
);
} catch (Throwable $exception) {
$this->error('订单明细查询失败(' . $source['items'] . '):' . $exception->getMessage());
@ -215,6 +236,18 @@ class SyncStoreReceiptProductCost extends Command
$warehouse = '';
}
}
// 发货仓库过滤(在 TCXJ/FU WU 之后执行):
// 默认排除常州简尚家居有限公司 / 简尚-苏州福利品仓 / 简尚-自动发货仓;
// 例外:常州简尚家居有限公司里「DF-」开头的商品行保留。
if (in_array($warehouse, self::EXCLUDED_WAREHOUSES, true)) {
$isDfException = $warehouse === '常州简尚家居有限公司'
&& str_starts_with(mb_strtoupper($sku), 'DF-');
if (! $isDfException) {
continue;
}
}
// 组合装条码(ZH-条码-数量-条码-数量)按子订单拆成组件行:
// 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。
// 解析不了的组合条码(条码被截断等)保持原样,避免丢行。
@ -265,9 +298,7 @@ class SyncStoreReceiptProductCost extends Command
foreach ($billByCore as $core => $bill) {
foreach ($this->costLines($lines[$core] ?? []) as $line) {
$short = $shortNames['by_warehouse'][$line['warehouse']]
?? $shortNames['by_provider'][$line['warehouse']]
?? '';
$short = $shortNames['by_provider'][$line['warehouse']] ?? '';
$price = $short !== '' ? ($prices[$short . "\0" . $line['sku']] ?? null) : null;
$priceSource = $price === null ? null : self::PRICE_SOURCE_WEIGHTED;
// 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。
@ -459,10 +490,14 @@ class SyncStoreReceiptProductCost extends Command
$expanded = [];
foreach ($components as [$code, $multiplier]) {
// 组合装里如果拆出套餐/服务占位编码,同样不取数。
if (in_array(mb_strtoupper(trim($code)), self::EXCLUDED_SKUS, true)) {
continue;
}
$expanded[] = [$code, $quantity * $multiplier, true];
}
return $expanded;
return $expanded === [] ? [] : $expanded;
}
/**
@ -476,7 +511,6 @@ class SyncStoreReceiptProductCost extends Command
private function warehouseShortNames(): array
{
$byProvider = [];
$byWarehouse = [];
foreach (DB::table(self::WAREHOUSE_MAPPING_TABLE)
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']) as $mapping) {
$provider = trim((string) $mapping->warehouse_provider);
@ -485,19 +519,30 @@ class SyncStoreReceiptProductCost extends Command
if ($short === '') {
continue;
}
if ($warehouse !== '' && ! isset($byWarehouse[$warehouse])) {
$byWarehouse[$warehouse] = $short;
}
if ($provider === '') {
continue;
}
// 只认「仓储方 + 仓库含主仓」的那条:发货仓库是仓储方级别的名称,
// 次品仓 / 销退仓 / 客退仓这些行的简称(多为「-不看」)不能套到主仓发货上。
$isMain = str_contains($warehouse, '主仓');
if ($isMain || ! isset($byProvider[$provider])) {
if ($isMain) {
$byProvider[$provider] = $short;
continue;
}
// 映射表没有主仓行时,仅接受「仓库名 = 仓储方名」这种同名仓库(如重庆渝北仓),
// 其余(次品仓、配件单仓等)不再兜底,避免把主仓订单误判成不看仓。
if ($warehouse === $provider && ! isset($byProvider[$provider])) {
$byProvider[$provider] = $short;
continue;
}
// 业务确认:常州简尚家居有限公司的发货仓对应映射表里的「配件单仓」行
// (仓储方=常州简尚家居有限公司 / 仓库=配件单仓 → 配件单仓-不看)。
if (str_contains($warehouse, '配件单仓') && ! isset($byProvider[$provider])) {
$byProvider[$provider] = $short;
}
}
return ['by_provider' => $byProvider, 'by_warehouse' => $byWarehouse];
return ['by_provider' => $byProvider];
}
/**

View File

@ -75,8 +75,8 @@ class VerifyErpDailyBillCoverage extends Command
$month = $from->copy()->startOfMonth();
while ($month->lte($to)) {
$monthEnd = $month->copy()->endOfMonth();
$requiredStart = $month->lt($from) ? $from->copy() : $month->copy();
$requiredThrough = $monthEnd->gt($to) ? $to->copy() : $monthEnd->copy();
$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())

View File

@ -78,7 +78,7 @@ class Kernel extends ConsoleKernel
// 财务台账:从 AI 库 rpt 同步动账明细,按日重算账户流水。
// 生产宿主机 cron 仍需配置对应的 finance-ledger:sync-rpt 入口。
$schedule->command('finance-ledger:sync-rpt')
$schedule->command('finance-ledger:sync-rpt --days=7 --merge')
->dailyAt('02:30')
->timezone('Asia/Shanghai')
->withoutOverlapping(120)

View File

@ -2,15 +2,500 @@
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountType;
use App\Utilities\FinanceLedgerAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate;
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk;
use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FinanceLedger extends Controller
{
public function downloadAccountTemplate(FinanceLedgerAccountTemplate $template): StreamedResponse
{
return $template->download();
}
public function unavailableBalanceTasks(Request $request)
{
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$abnormalStatuses = array_values(array_diff(
array_keys(FinanceLedgerAccountStatus::OPTIONS),
[FinanceLedgerAccountStatus::NORMAL],
));
$handlingMethods = [
'online' => '线上',
'offline' => '线下',
];
$completionStatuses = [
'completed' => '已完成',
'in_progress' => '进行中',
'not_started' => '待开始',
'incomplete' => '未完成',
];
$filters = [
'pending_short' => trim((string) $request->query('pending_short', '')),
'pending_type' => trim((string) $request->query('pending_type', '')),
'pending_status' => trim((string) $request->query('pending_status', '')),
'pending_reason' => trim((string) $request->query('pending_reason', '')),
'pending_method' => trim((string) $request->query('pending_method', '')),
'pending_owner' => trim((string) $request->query('pending_owner', '')),
];
$query = $connection->table('fin_accounts as accounts')
->join('fin_account_statuses as account_status', 'account_status.account_short', '=', 'accounts.account_short')
->leftJoin('fin_account_type_overrides as type_override', 'type_override.account_short', '=', 'accounts.account_short')
->leftJoin('fin_account_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short')
->whereIn('account_status.status', $abnormalStatuses);
if ($filters['pending_short'] !== '') {
$query->where('accounts.account_short', 'like', '%' . $filters['pending_short'] . '%');
}
if ($filters['pending_type'] !== '') {
$query->where(function ($effectiveType) use ($filters): void {
$effectiveType->where(function ($baseType) use ($filters): void {
$baseType->whereNull('type_override.id');
if ($filters['pending_type'] === '__empty__') {
$baseType->where(function ($blank): void {
$blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', '');
});
} else {
$baseType->where('accounts.account_type', $filters['pending_type']);
}
})->orWhere(function ($overrideType) use ($filters): void {
$overrideType->whereNotNull('type_override.id');
if ($filters['pending_type'] === '__empty__') {
$overrideType->where(function ($blank): void {
$blank->whereNull('type_override.account_type')->orWhere('type_override.account_type', '');
});
} else {
$overrideType->where('type_override.account_type', $filters['pending_type']);
}
});
});
}
if ($filters['pending_status'] !== '' && array_key_exists($filters['pending_status'], FinanceLedgerAccountStatus::OPTIONS)) {
$query->where('account_status.status', $filters['pending_status']);
}
if ($filters['pending_reason'] !== '') {
$query->where('account_todo.abnormal_reason', 'like', '%' . $filters['pending_reason'] . '%');
}
if ($filters['pending_method'] !== '' && array_key_exists($filters['pending_method'], $handlingMethods)) {
$query->where('account_todo.handling_method', $filters['pending_method']);
}
if ($filters['pending_owner'] !== '') {
$query->where('account_todo.owner_name', 'like', '%' . $filters['pending_owner'] . '%');
}
$summaryQuery = clone $query;
$summary = [
'count' => (clone $query)->count(),
'current_balance' => round((float) (clone $summaryQuery)->sum('accounts.current_balance'), 2),
'balance' => round((float) (clone $summaryQuery)->sum('accounts.daily_available'), 2),
'blocked' => round((float) (clone $summaryQuery)->sum('accounts.blocked_amount'), 2),
'daily_available' => round((float) (clone $summaryQuery)->sum('accounts.daily_available'), 2),
];
$sortField = (string) $request->query('pending_sort', '');
$sortField = in_array($sortField, ['started_at', 'completed_at'], true) ? $sortField : '';
$sortDirection = strtolower((string) $request->query('pending_direction', 'asc'));
$sortDirection = $sortDirection === 'desc' ? 'desc' : 'asc';
$query->select([
'accounts.*',
'account_status.status as effective_account_status',
'type_override.id as type_override_id',
'type_override.account_type as effective_account_type',
'account_todo.abnormal_reason as todo_abnormal_reason',
'account_todo.handling_method as todo_handling_method',
'account_todo.owner_name as todo_owner_name',
'account_todo.started_at as todo_started_at',
'account_todo.completed_at as todo_completed_at',
'account_todo.current_progress as todo_current_progress',
'account_todo.completion_status as todo_completion_status',
]);
if ($sortField !== '') {
$query->orderBy('account_todo.' . $sortField, $sortDirection);
} else {
$query->orderByDesc('accounts.blocked_amount');
}
$rows = $query
->orderBy('accounts.company_name')
->orderBy('accounts.account_short')
->paginate(50)
->appends($filters)
->appends(array_filter([
'pending_sort' => $sortField,
'pending_direction' => $sortField !== '' ? $sortDirection : '',
]))
->withQueryString();
$rows->getCollection()->transform(static function ($row) {
$row->account_status = FinanceLedgerAccountStatus::normalize($row->effective_account_status ?? null);
if ($row->type_override_id !== null) {
$row->account_type = (string) ($row->effective_account_type ?? '');
}
$row->todo_completion_status = $row->todo_completion_status ?: 'not_started';
return $row;
});
$typeOptions = FinanceLedgerAccountType::options(
$connection->table('fin_accounts')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
$connection->table('fin_account_type_overrides')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
);
$reasonOptions = $connection->table('fin_account_todos')
->whereNotNull('abnormal_reason')->where('abnormal_reason', '<>', '')
->distinct()->orderBy('abnormal_reason')->pluck('abnormal_reason')->all();
$ownerOptions = $connection->table('fin_account_todos')
->whereNotNull('owner_name')->where('owner_name', '<>', '')
->distinct()->orderBy('owner_name')->pluck('owner_name')->all();
return view('business.pages.financial-ledger.unavailable-balance-tasks', [
'rows' => $rows,
'summary' => $summary,
'filters' => $filters,
'sortField' => $sortField,
'sortDirection' => $sortDirection,
'backUrl' => route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'ledger',
]) . '?tab=accounts',
'canEdit' => FinanceLedgerAccess::canEditAccountStatus(user()),
'handlingMethods' => $handlingMethods,
'completionStatuses' => $completionStatuses,
'accountStatusOptions' => array_intersect_key(
FinanceLedgerAccountStatus::OPTIONS,
array_flip($abnormalStatuses),
),
'accountTypeOptions' => $typeOptions,
'reasonOptions' => $reasonOptions,
'ownerOptions' => $ownerOptions,
]);
}
public function unretainedAccountTasks(Request $request)
{
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$handlingMethods = [
'online' => '线上',
'offline' => '线下',
];
$completionStatuses = [
'completed' => '已完成',
'in_progress' => '进行中',
'not_started' => '待开始',
];
$filters = [
'keep_short' => trim((string) $request->query('keep_short', '')),
'keep_type' => trim((string) $request->query('keep_type', '')),
'keep_status' => trim((string) $request->query('keep_status', '')),
'keep_method' => trim((string) $request->query('keep_method', '')),
'keep_owner' => trim((string) $request->query('keep_owner', '')),
'keep_progress' => trim((string) $request->query('keep_progress', '')),
];
$query = $connection->table('fin_accounts as accounts')
->leftJoin('fin_account_statuses as account_status', 'account_status.account_short', '=', 'accounts.account_short')
->leftJoin('fin_account_type_overrides as type_override', 'type_override.account_short', '=', 'accounts.account_short')
->leftJoin('fin_account_keep_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short')
->select([
'accounts.*',
'account_status.status as effective_account_status',
'type_override.id as type_override_id',
'type_override.account_type as effective_account_type',
'account_todo.handling_method as todo_handling_method',
'account_todo.owner_name as todo_owner_name',
'account_todo.started_at as todo_started_at',
'account_todo.completed_at as todo_completed_at',
'account_todo.current_progress as todo_current_progress',
]);
FinanceLedgerAccountShouldKeep::applyUnretainedCondition(
$query,
'accounts.account_should_keep',
'accounts.company_plan',
);
if ($filters['keep_short'] !== '') {
$query->where('accounts.account_short', 'like', '%' . $filters['keep_short'] . '%');
}
if ($filters['keep_type'] !== '') {
$query->where(function ($effectiveType) use ($filters): void {
$effectiveType->where(function ($baseType) use ($filters): void {
$baseType->whereNull('type_override.id');
if ($filters['keep_type'] === '__empty__') {
$baseType->where(function ($blank): void {
$blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', '');
});
} else {
$baseType->where('accounts.account_type', $filters['keep_type']);
}
})->orWhere(function ($overrideType) use ($filters): void {
$overrideType->whereNotNull('type_override.id');
if ($filters['keep_type'] === '__empty__') {
$overrideType->where(function ($blank): void {
$blank->whereNull('type_override.account_type')->orWhere('type_override.account_type', '');
});
} else {
$overrideType->where('type_override.account_type', $filters['keep_type']);
}
});
});
}
if ($filters['keep_status'] !== '' && array_key_exists($filters['keep_status'], FinanceLedgerAccountStatus::OPTIONS)) {
if ($filters['keep_status'] === FinanceLedgerAccountStatus::NORMAL) {
$query->where(function ($normalStatus): void {
$normalStatus->whereNull('account_status.status')
->orWhere('account_status.status', FinanceLedgerAccountStatus::NORMAL);
});
} else {
$query->where('account_status.status', $filters['keep_status']);
}
}
if ($filters['keep_method'] !== '' && array_key_exists($filters['keep_method'], $handlingMethods)) {
$query->where('account_todo.handling_method', $filters['keep_method']);
}
if ($filters['keep_owner'] !== '') {
$query->where('account_todo.owner_name', 'like', '%' . $filters['keep_owner'] . '%');
}
if ($filters['keep_progress'] !== '' && array_key_exists($filters['keep_progress'], $completionStatuses)) {
if ($filters['keep_progress'] === 'not_started') {
$query->where(function ($notStarted): void {
$notStarted->whereNull('account_todo.current_progress')
->orWhere('account_todo.current_progress', 'not_started');
});
} else {
$query->where('account_todo.current_progress', $filters['keep_progress']);
}
}
$summaryQuery = clone $query;
$summary = [
'count' => (clone $query)->count(),
'in_progress' => (clone $summaryQuery)->where('account_todo.current_progress', 'in_progress')->count(),
'not_started' => (clone $summaryQuery)->where(function ($notStarted): void {
$notStarted->whereNull('account_todo.current_progress')
->orWhere('account_todo.current_progress', 'not_started');
})->count(),
'completed' => (clone $summaryQuery)->where('account_todo.current_progress', 'completed')->count(),
];
$sortField = (string) $request->query('keep_sort', '');
$sortField = in_array($sortField, ['started_at', 'completed_at'], true) ? $sortField : '';
$sortDirection = strtolower((string) $request->query('keep_direction', 'asc'));
$sortDirection = $sortDirection === 'desc' ? 'desc' : 'asc';
if ($sortField !== '') {
$query->orderBy('account_todo.' . $sortField, $sortDirection);
}
$rows = $query
->orderBy('accounts.company_name')
->orderBy('accounts.account_short')
->paginate(50)
->appends($filters)
->appends(array_filter([
'keep_sort' => $sortField,
'keep_direction' => $sortField !== '' ? $sortDirection : '',
]))
->withQueryString();
$rows->getCollection()->transform(static function ($row) {
$row->account_status = FinanceLedgerAccountStatus::normalize($row->effective_account_status ?? null);
if ($row->type_override_id !== null) {
$row->account_type = (string) ($row->effective_account_type ?? '');
}
$row->todo_current_progress = $row->todo_current_progress ?: 'not_started';
return $row;
});
$typeOptions = FinanceLedgerAccountType::options(
$connection->table('fin_accounts')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
$connection->table('fin_account_type_overrides')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
);
$ownerOptions = $connection->table('fin_account_keep_todos')
->whereNotNull('owner_name')->where('owner_name', '<>', '')
->distinct()->orderBy('owner_name')->pluck('owner_name')->all();
return view('business.pages.financial-ledger.unretained-account-tasks', [
'rows' => $rows,
'summary' => $summary,
'filters' => $filters,
'sortField' => $sortField,
'sortDirection' => $sortDirection,
'backUrl' => route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'ledger',
]) . '?tab=accounts',
'canEdit' => FinanceLedgerAccess::canEditAccountStatus(user()),
'handlingMethods' => $handlingMethods,
'completionStatuses' => $completionStatuses,
'accountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS,
'accountTypeOptions' => $typeOptions,
'ownerOptions' => $ownerOptions,
]);
}
public function updateAccountKeepTodo(Request $request)
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改不保留账户待办。');
$data = $request->validate([
'account_short' => ['required', 'string', 'max:100'],
'handling_method' => ['nullable', 'string', Rule::in(['online', 'offline'])],
'owner_name' => ['nullable', 'string', 'max:100'],
'started_at' => ['nullable', 'date'],
'completed_at' => ['nullable', 'date'],
'current_progress' => ['required', 'string', Rule::in(['completed', 'in_progress', 'not_started'])],
]);
$accountShort = trim((string) $data['account_short']);
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$account = $connection->table('fin_accounts')
->where('account_short', $accountShort)
->first(['account_should_keep', 'company_plan']);
abort_if($account === null, 422, '账户简称不存在,请刷新待办清单后重试。');
abort_unless(
FinanceLedgerAccountShouldKeep::display(
$account->account_should_keep ?? null,
$account->company_plan ?? null,
) === FinanceLedgerAccountShouldKeep::NO,
422,
'账户当前已设置为保留,无需继续填写不保留账户待办。',
);
$now = now();
$values = [
'handling_method' => $data['handling_method'] ?? null,
'owner_name' => $data['owner_name'] ?? null,
'started_at' => filled($data['started_at'] ?? null)
? Carbon::parse($data['started_at'])->format('Y-m-d H:i:s')
: null,
'completed_at' => filled($data['completed_at'] ?? null)
? Carbon::parse($data['completed_at'])->format('Y-m-d H:i:s')
: null,
'current_progress' => (string) $data['current_progress'],
'updated_by' => (string) user()->name,
'updated_at' => $now,
];
$todos = $connection->table('fin_account_keep_todos');
$existing = $todos->where('account_short', $accountShort)->exists();
if ($existing) {
$todos->where('account_short', $accountShort)->update($values);
} else {
$todos->insert($values + [
'account_short' => $accountShort,
'created_at' => $now,
]);
}
return redirect()->back()->with('success', '不保留账户待办已保存');
}
public function updateAccountTodo(Request $request, FinanceLedgerCalculator $calculator)
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改待办事项。');
$data = $request->validate([
'account_short' => ['required', 'string', 'max:100'],
'abnormal_reason' => ['nullable', 'string', 'max:1000'],
'handling_method' => ['nullable', 'string', Rule::in(['online', 'offline'])],
'owner_name' => ['nullable', 'string', 'max:100'],
'started_at' => ['nullable', 'date'],
'completed_at' => ['nullable', 'date'],
'current_progress' => ['nullable', 'string', 'max:100'],
'completion_status' => ['nullable', 'string', Rule::in(['completed', 'in_progress', 'not_started', 'incomplete'])],
]);
$accountShort = trim((string) $data['account_short']);
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
abort_unless(
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
422,
'账户简称不存在,请刷新待办清单后重试。',
);
$abnormalStatuses = array_values(array_diff(
array_keys(FinanceLedgerAccountStatus::OPTIONS),
[FinanceLedgerAccountStatus::NORMAL],
));
$status = $connection->table('fin_account_statuses')
->where('account_short', $accountShort)
->value('status');
abort_unless(
$status !== null && in_array(FinanceLedgerAccountStatus::normalize((string) $status), $abnormalStatuses, true),
422,
'账户当前已不是异常状态,无需继续填写待办。',
);
$now = now();
$completionStatus = (string) ($data['completion_status'] ?? 'not_started');
$values = [
'abnormal_reason' => $data['abnormal_reason'] ?? null,
'handling_method' => $data['handling_method'] ?? null,
'owner_name' => $data['owner_name'] ?? null,
'started_at' => filled($data['started_at'] ?? null)
? Carbon::parse($data['started_at'])->format('Y-m-d H:i:s')
: null,
'completed_at' => filled($data['completed_at'] ?? null)
? Carbon::parse($data['completed_at'])->format('Y-m-d H:i:s')
: null,
'current_progress' => $data['current_progress'] ?? null,
'completion_status' => $completionStatus,
'updated_by' => (string) user()->name,
'updated_at' => $now,
];
$connection->transaction(function () use ($connection, $accountShort, $values, $now, $completionStatus, $calculator): void {
$todos = $connection->table('fin_account_todos');
$existing = $todos->where('account_short', $accountShort)->exists();
if ($existing) {
$todos->where('account_short', $accountShort)->update($values);
} else {
$todos->insert($values + [
'account_short' => $accountShort,
'created_at' => $now,
]);
}
if ($completionStatus === 'completed') {
$connection->table('fin_account_statuses')
->where('account_short', $accountShort)
->update([
'status' => FinanceLedgerAccountStatus::NORMAL,
'updated_by' => (string) user()->name,
'updated_at' => $now,
]);
$calculator->refreshAccountAmounts($accountShort);
}
});
return redirect()->back()->with(
'success',
$completionStatus === 'completed'
? '待办已完成,账户状态已自动设为正常'
: '待办事项已保存',
);
}
public function updateMapping(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改账户映射。');
@ -59,4 +544,267 @@ class FinanceLedger extends Controller
'total' => $connection->table('fin_account_map')->count(),
], 200, ['Cache-Control' => 'private, no-store']);
}
public function updateAccountStatus(Request $request, FinanceLedgerCalculator $calculator): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户状态。');
$data = $request->validate([
'account_short' => ['required', 'string', 'max:100'],
'status' => ['required', 'string', Rule::in(array_keys(FinanceLedgerAccountStatus::OPTIONS))],
]);
$accountShort = trim((string) $data['account_short']);
abort_if($accountShort === '', 422, '账户简称不能为空。');
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
abort_unless(
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
422,
'账户简称不存在,请刷新账户总表后重试。',
);
$status = FinanceLedgerAccountStatus::normalize((string) $data['status']);
$statuses = $connection->table('fin_account_statuses');
$existing = $statuses->where('account_short', $accountShort)->first();
$now = now();
$values = [
'status' => $status,
'updated_by' => (string) user()->name,
'updated_at' => $now,
];
$connection->transaction(function () use ($statuses, $existing, $values, $accountShort, $now, $calculator): void {
if ($existing === null) {
$statuses->insert($values + [
'account_short' => $accountShort,
'created_at' => $now,
]);
} else {
$statuses->where('account_short', $accountShort)->update($values);
}
$calculator->refreshAccountAmounts($accountShort);
});
return response()->json([
'ok' => true,
'account_short' => $accountShort,
'status' => $status,
'label' => FinanceLedgerAccountStatus::label($status),
], 200, ['Cache-Control' => 'private, no-store']);
}
public function updateAccountType(Request $request): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditAccountType(user()), 403, '只有财务台账管理员可以修改账户类型。');
$data = $request->validate([
'account_short' => ['required', 'string', 'max:100'],
'account_type' => ['nullable', 'string', 'max:50'],
]);
$accountShort = trim((string) $data['account_short']);
$accountType = trim((string) ($data['account_type'] ?? ''));
abort_if($accountShort === '', 422, '账户简称不能为空。');
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$account = $connection->table('fin_accounts')
->where('account_short', $accountShort)
->first(['account_no', 'account_last4']);
abort_if($account === null, 422, '账户简称不存在,请刷新账户总表后重试。');
if ($accountType !== '') {
$allowedTypes = FinanceLedgerAccountType::options(
$connection->table('fin_accounts')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->pluck('account_type')->all(),
$connection->table('fin_account_type_overrides')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->pluck('account_type')->all(),
);
abort_unless(in_array($accountType, $allowedTypes, true), 422, '请选择现有账户类型。');
}
$overrides = $connection->table('fin_account_type_overrides');
$existing = $overrides->where('account_short', $accountShort)->first();
$now = now();
$values = [
'account_type' => $accountType !== '' ? $accountType : null,
'updated_by' => (string) user()->name,
'updated_at' => $now,
];
$accountLast4 = FinanceLedgerAccountType::last4(
$accountType,
(string) ($account->account_no ?? ''),
(string) ($account->account_last4 ?? ''),
);
$connection->transaction(function () use ($connection, $overrides, $existing, $values, $accountShort, $now, $accountLast4): void {
if ($existing === null) {
$overrides->insert($values + [
'account_short' => $accountShort,
'created_at' => $now,
]);
} else {
$overrides->where('account_short', $accountShort)->update($values);
}
$connection->table('fin_accounts')
->where('account_short', $accountShort)
->update([
'account_last4' => $accountLast4,
'updated_at' => $now,
]);
});
return response()->json([
'ok' => true,
'account_short' => $accountShort,
'account_type' => $accountType,
'label' => $accountType !== '' ? $accountType : '未填写',
'account_last4' => $accountLast4,
], 200, ['Cache-Control' => 'private, no-store']);
}
public function updateAccountPlan(Request $request): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改公司规划。');
$data = $request->validate([
'account_short' => ['required', 'string', 'max:100'],
'company_plan' => ['nullable', 'string', 'max:50'],
]);
$accountShort = trim((string) $data['account_short']);
$plan = trim((string) ($data['company_plan'] ?? ''));
if ($accountShort === '') {
abort(422, '账户简称不能为空。');
}
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
abort_unless(
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
422,
'账户简称不存在,请刷新账户总表后重试。',
);
$allowedPlans = $connection->table('fin_accounts')
->whereNotNull('company_plan')
->where('company_plan', '<>', '')
->distinct()
->pluck('company_plan')
->map(static fn (mixed $value): string => trim((string) $value))
->filter()
->values()
->all();
abort_unless($plan === '' || in_array($plan, $allowedPlans, true), 422, '请选择现有公司规划。');
$connection->table('fin_accounts')->where('account_short', $accountShort)->update([
'company_plan' => $plan !== '' ? $plan : null,
'updated_at' => now(),
]);
return response()->json([
'ok' => true,
'account_short' => $accountShort,
'company_plan' => $plan,
'label' => $plan !== '' ? $plan : '未填写',
], 200, ['Cache-Control' => 'private, no-store']);
}
public function updateAccountKeep(Request $request): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户是否保留。');
$data = $request->validate([
'account_short' => ['required', 'string', 'max:100'],
'account_should_keep' => ['nullable', 'string', Rule::in(FinanceLedgerAccountShouldKeep::OPTIONS)],
]);
$accountShort = trim((string) $data['account_short']);
$keep = FinanceLedgerAccountShouldKeep::normalize((string) ($data['account_should_keep'] ?? ''));
if ($accountShort === '') {
abort(422, '账户简称不能为空。');
}
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
abort_unless(
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
422,
'账户简称不存在,请刷新账户总表后重试。',
);
$connection->table('fin_accounts')->where('account_short', $accountShort)->update([
'account_should_keep' => $keep !== '' ? $keep : null,
'updated_at' => now(),
]);
return response()->json([
'ok' => true,
'account_short' => $accountShort,
'account_should_keep' => $keep,
'label' => $keep !== '' ? $keep : '未填写',
], 200, ['Cache-Control' => 'private, no-store']);
}
public function previewShopMapping(FinanceLedgerShopMappingBulk $bulk): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。');
$preview = $bulk->preview();
return response()->json([
'ok' => true,
'total' => $preview['total'],
'matched' => $preview['matched'],
'unmatched' => $preview['unmatched'],
'sample' => array_slice($preview['candidates'], 0, 10),
'unmatched_sample' => array_slice($preview['unmatched_values'], 0, 10),
], 200, ['Cache-Control' => 'private, no-store']);
}
public function applyShopMapping(FinanceLedgerShopMappingBulk $bulk): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。');
$result = $bulk->apply(user());
return response()->json([
'ok' => true,
'total' => $result['total'],
'mapped' => $result['mapped'],
'unmatched' => $result['unmatched'],
], 200, ['Cache-Control' => 'private, no-store']);
}
public function previewPayableMapping(FinanceLedgerPayableMappingBulk $bulk): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。');
$preview = $bulk->preview();
return response()->json([
'ok' => true,
'total' => $preview['total'],
'eligible' => $preview['eligible'],
'ambiguous' => $preview['ambiguous'],
'missing' => $preview['missing'],
'sample' => array_slice($preview['candidates'], 0, 10),
'ambiguous_sample' => array_slice($preview['ambiguous_values'], 0, 10),
'missing_sample' => array_slice($preview['missing_values'], 0, 10),
], 200, ['Cache-Control' => 'private, no-store']);
}
public function applyPayableMapping(FinanceLedgerPayableMappingBulk $bulk): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。');
$result = $bulk->apply(user());
return response()->json([
'ok' => true,
'total' => $result['total'],
'mapped' => $result['mapped'],
'ambiguous' => $result['ambiguous'],
'missing' => $result['missing'],
], 200, ['Cache-Control' => 'private, no-store']);
}
}

View File

@ -372,6 +372,10 @@ class Pages extends Controller
$jdSofiaGuobu = '(' . $jdSofiaPay . ' - COALESCE(income_amount, 0)) > 0.005';
$rules['差额=订单实付包含商家承担优惠和以旧换新国补'] = $jdSofiaGuard . ' AND ' . $jdSofiaGuobu;
$rules['差额=订单实付包含商家承担优惠'] = $jdSofiaGuard . ' AND NOT (' . $jdSofiaGuobu . ')';
// 拼多多全额退款:先于通用「未减优惠/冲减」判定,避免抵减项把退款单误标成其他原因。
$pddFullRefundHit = "platform = '拼多多'"
. ' AND ABS(COALESCE(income_amount, 0) + COALESCE(refund_amount, 0)) <= 0.02';
$rules['订单实付含已退款部分'] = $pddFullRefundHit;
// C 方案:这三条是"通用等式档"——口径(卖家实收 / 应付已付)能对上账单的行让位给口径兜底
// (归零 + 口径标识),只有口径也对不上时才显示这三档,避免把口径差标成一堆"未减优惠/冲减"。
$notBasisMarked = self::receiptReasonNotBasisMarkedSql();
@ -410,7 +414,18 @@ class Pages extends Controller
. ' OR ' . $refundGapHit($refundEvidence['cancelled_sum'])
. ' OR ' . $refundGapNetHit($refundEvidence['refund_sum'])
. ' OR ' . $refundFullHit($refundEvidence['refund_sum'])
. ') ELSE 0 END = 1) OR ' . $taoRefundHit;
. ') ELSE 0 END = 1) OR ' . $taoRefundHit . ' OR ' . $pddFullRefundHit;
// 拼多多部分退款:账单收入高于聚水潭买家实付,且扣除优惠/冲减后仍有正向小额差额,
// 与「多多支付立减」等平台支付类补贴一致;只标注原因,不改差额和收入。
$pddPaymentDiscount = '(' . $effective . ' - COALESCE(discount_amount, 0) - COALESCE(offset_amount, 0))';
$rules['差额=平台支付立减'] = "platform = '拼多多'"
. " AND match_status = '已匹配'"
. ' AND ' . $effective . ' > 0.005'
. ' AND COALESCE(refund_amount, 0) < -0.005'
. ' AND ' . $pddPaymentDiscount . ' > 0.005'
. ' AND ' . $pddPaymentDiscount . ' <= 20.005'
. ' AND ' . $notBasisMarked;
// 1688 先采后付订单的订单实付包含平台红包补贴,但支付宝实际到账是扣除补贴后的现金;
// 仅对已匹配、负差额、无优惠/冲减/退款且当前没有口径标识的行标注,不改差额金额。
@ -601,6 +616,11 @@ class Pages extends Controller
if (abs($product) < 0.005) {
return ($row->planting_amount ?? null) !== null ? $label . '取数为0(种菜)' : $label . '取数为0';
}
// 拼多多全额退款优先于其它抵减等式:退款后实际净收款为 0,不应再落成优惠/冲减异常。
if (($row->platform ?? '') === '拼多多'
&& abs($income + (float) ($row->refund_amount ?? 0)) <= 0.02) {
return '订单实付含已退款部分';
}
// 得物负差额:差额与联合营销费未让利尾数吻合(营销费支出 + 差额恰为整数)→「订单实付未减联合营销费」。
// 放在通用「未减冲减/优惠」等式之前:得物这类行的差额常恰好等于冲减(营销费)金额,需优先给出具体项备注。
if (($row->platform ?? '') === '得物' && $difference < -0.005) {
@ -707,6 +727,17 @@ class Pages extends Controller
}
}
// 拼多多部分退款:差额扣除优惠/冲减后,剩余部分与平台支付立减一致。
$pddPaymentDiscount = $difference - $discount - $offset;
if (($row->platform ?? '') === '拼多多'
&& ! $basisMarked
&& $difference > 0.005
&& (float) ($row->refund_amount ?? 0) < -0.005
&& $pddPaymentDiscount > 0.005
&& $pddPaymentDiscount <= 20.005) {
return '差额=平台支付立减';
}
// 原因优先:上面的规则都没命中时,如果这行的口径(卖家实收 / 应付已付)能对上账单,
// 就按口径兜底——差额按 0 展示、口径标识在「匹配原因」列,不再当异常。
$rawRemark = trim((string) ($row->remark ?? ''));
@ -1661,8 +1692,8 @@ class Pages extends Controller
// 联动统计要跑几次全表聚合,短时间内重复请求(页面加载 + 下拉刷新)直接吃缓存
$cacheKey = 'performance-details-facets:' . md5(serialize([$month, $filters, $orderNo]));
// 数据按天同步,缓存 10 分钟:大幅降低大平台(拼多多等)的联动等待。
return response()->json(cache()->remember($cacheKey, now()->addSeconds(600), function () use ($month, $filters, $orderNo) {
// 数据按天同步;联动统计会扫全月,缓存 1 小时,避免相同筛选反复等待。
return response()->json(cache()->remember($cacheKey, now()->addHour(), function () use ($month, $filters, $orderNo) {
return [
'month' => substr($month, 0, 7),
'facets' => $this->receiptOrderMatchFacets($month, $filters, $orderNo),
@ -2178,8 +2209,6 @@ class Pages extends Controller
'online_order_no' => trim((string) request('online_order_no', '')),
'product_code' => trim((string) request('product_code', '')),
'warehouse_short_name' => trim((string) request('warehouse_short_name', '')),
'missing_price' => request()->boolean('missing_price') ? '1' : '',
'include_excluded' => request()->boolean('include_excluded') ? '1' : '',
];
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
@ -2199,7 +2228,9 @@ class Pages extends Controller
];
}
$applyFilters = static function ($query, bool $excludedWarehouseOnly = false) use ($filters): void {
// 页面不再提供「只看未匹配单价 / 包含-不看仓」两个开关:默认全部数据展示,
// 已匹配与未匹配单价、所有仓库(含「-不看」仓)都纳入统计与列表。
$applyFilters = static function ($query) use ($filters): void {
$query->where('report_month', $filters['month'] . '-01');
if ($filters['platform'] !== '') {
$query->where('platform', $filters['platform']);
@ -2212,32 +2243,12 @@ class Pages extends Controller
if ($filters['warehouse_short_name'] !== '') {
$query->where('warehouse_short_name', $filters['warehouse_short_name']);
}
if ($filters['missing_price'] === '1') {
$query->whereNull('unit_price');
}
// 仓库映射表里标「不看」的仓(次品 / 销退 / 虚拟 / 信誉 / 京东云仓等)默认不展示:
// 这些仓在商品月加权平均单价里本来就没有价,展示出来只会让「未匹配单价」虚高。
if ($excludedWarehouseOnly) {
$query->where('warehouse_short_name', 'like', '%-不看%');
return;
}
if ($filters['include_excluded'] !== '1') {
$query->where(static function ($inner): void {
$inner->whereNull('warehouse_short_name')
->orWhere('warehouse_short_name', 'not like', '%-不看%');
});
}
};
$countQuery = $base();
$applyFilters($countQuery);
$total = (int) $countQuery->count();
$excludedQuery = $base();
$applyFilters($excludedQuery, true);
$excludedRows = (int) $excludedQuery->count();
$aggQuery = $base();
$applyFilters($aggQuery);
$agg = $aggQuery->selectRaw('
@ -2291,7 +2302,6 @@ class Pages extends Controller
'missing_price_rows' => (int) ($agg->missing_price_rows ?? 0),
'missing_price_quantity' => round((float) ($agg->missing_price_quantity ?? 0), 2),
'missing_warehouse_rows' => (int) ($agg->missing_warehouse_rows ?? 0),
'excluded_rows' => $excludedRows,
'fallback_price_rows' => (int) ($agg->fallback_price_rows ?? 0),
'catalog_price_rows' => (int) ($agg->catalog_price_rows ?? 0),
'unit_mismatch_rows' => (int) ($agg->unit_mismatch_rows ?? 0),
@ -2573,15 +2583,7 @@ class Pages extends Controller
if ($warehouse !== '') {
$query->where('warehouse_short_name', $warehouse);
}
if ($request->boolean('missing_price')) {
$query->whereNull('unit_price');
}
if (! $request->boolean('include_excluded')) {
$query->where(static function ($inner): void {
$inner->whereNull('warehouse_short_name')
->orWhere('warehouse_short_name', 'not like', '%-不看%');
});
}
// 导出与页面一致:全部数据(含未匹配单价与「-不看」仓)。
$filename = '商品成本_' . ($month === null ? 'all' : substr($month, 0, 7)) . '.csv';
@ -5875,6 +5877,8 @@ class Pages extends Controller
$columns = [
['label' => '序号/主键ID', 'field' => 'Id', 'identifier' => true],
['label' => '退款日期', 'field' => 'refundDate'],
['label' => '业务来源', 'field' => 'dataSource'],
['label' => '商品明细', 'field' => 'detailStatus'],
['label' => '售后单ID', 'field' => 'as_id', 'identifier' => true],
['label' => '外部售后单号', 'field' => 'outer_as_id', 'identifier' => true],
['label' => '订单ID', 'field' => 'o_id', 'identifier' => true],

View File

@ -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)));
}
}

View 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',
]);
}
}

View File

@ -2,10 +2,12 @@
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;
@ -29,6 +31,8 @@ class SupplierReconciliation extends Controller
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');
@ -46,13 +50,9 @@ class SupplierReconciliation extends Controller
{
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'],
'supplier' => ['required', 'string', 'max:191'],
]);
$supplier = trim((string) $request->input('supplier'));
if ($supplier === '') {
abort(422, '请选择厂家。');
}
$supplier = $this->effectiveSupplier($request);
$records = $this->template->parse($request->file('import'), $supplier);
$rows = $this->statements->save(
@ -105,6 +105,23 @@ class SupplierReconciliation extends Controller
return $this->returnCsvExporter->download($rows, $supplier);
}
public function exportComparison(Request $request): StreamedResponse
{
$request->validate([
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
]);
[$supplier, $companyId] = $this->returnTarget($request);
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->query());
return $this->comparisonCsvExporter->download(
$detail,
$supplier,
trim((string) $request->query('month', '')),
trim((string) $request->query('reconciler', '')),
);
}
public function saveDingTalkRobot(Request $request): RedirectResponse
{
$request->validate([
@ -131,9 +148,10 @@ class SupplierReconciliation extends Controller
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
'scope' => ['required', 'in:all,row,selected'],
'purchase_no' => ['nullable', 'string', 'max:191'],
'date' => ['nullable', 'string', 'max:32'],
'barcode' => ['nullable', 'string', 'max:191'],
'difference_type' => ['nullable', 'string', 'max:32'],
'unit_price' => ['nullable', 'numeric'],
'selected_rows' => ['nullable', 'array', 'max:600'],
'selected_rows.*' => ['string', 'max:2048'],
]);
@ -145,14 +163,16 @@ class SupplierReconciliation extends Controller
));
if ($request->input('scope') === 'row') {
$purchaseNo = trim((string) $request->input('purchase_no'));
$date = trim((string) $request->input('date'));
$barcode = trim((string) $request->input('barcode'));
$type = trim((string) $request->input('difference_type'));
$unitPrice = (float) $request->input('unit_price');
$differences = array_values(array_filter(
$differences,
static fn (array $row): bool => (string) ($row['barcode'] ?? '') === $barcode
static fn (array $row): bool => (string) ($row['purchaseNo'] ?? '') === $purchaseNo
&& (string) ($row['date'] ?? '') === $date
&& (string) ($row['barcode'] ?? '') === $barcode
&& (string) ($row['type'] ?? '') === $type
&& abs((float) ($row['unitPrice'] ?? 0) - $unitPrice) < 0.005,
));
} elseif ($request->input('scope') === 'selected') {
$selected = [];
@ -191,9 +211,14 @@ class SupplierReconciliation extends Controller
{
$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,
(string) $request->input('key'),
$key,
$status,
$companyId,
user_id() ? (int) user_id() : null,
@ -205,18 +230,34 @@ class SupplierReconciliation extends Controller
/** @return array{0:string,1:int} */
private function returnTarget(Request $request): array
{
$request->validate(['supplier' => ['required', 'string', 'max:191']]);
return [$this->effectiveSupplier($request), (int) (company_id() ?: 1)];
}
return [trim((string) $request->input('supplier')), (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'] ?? '')),
number_format((float) ($row['unitPrice'] ?? 0), 4, '.', ''),
]);
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Services\Auth\SupplierPortalAccess;
use App\Utilities\SidebarPermissions;
use Closure;
use Illuminate\Http\Request;
@ -12,7 +13,35 @@ class EnsureLimitedDingTalkPageAccess
{
$user = $request->user();
$route = $request->route();
if (! $user || ! $route?->hasParameter('company_id') || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) {
if (! $user || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) {
return $next($request);
}
if (SupplierPortalAccess::isSupplierUser($user)) {
if ($route?->named('logout', 'business.supplier-portal.home')) {
return $next($request);
}
if ($route?->named('dashboard') && $route->hasParameter('company_id')) {
return redirect()->route('business.supplier-portal.home', [
'company_id' => $route->parameter('company_id'),
]);
}
if ($route?->named('business.pages.show')
&& $route->parameter('section') === 'supplier-reconciliation'
&& $route->parameter('page') === 'supplier-data') {
return $next($request);
}
foreach ($route?->gatherMiddleware() ?? [] as $middleware) {
if (is_string($middleware) && str_starts_with($middleware, 'business.page:')
&& str_replace(',', '/', substr($middleware, strlen('business.page:'))) === SupplierPortalAccess::PAGE_KEY) {
return $next($request);
}
}
abort(403, '当前账号没有该页面权限。');
}
if (! $route?->hasParameter('company_id')) {
return $next($request);
}

View File

@ -3,8 +3,9 @@
namespace App\Http\Middleware;
use App\Models\Auth\DingTalkIdentity;
use App\Services\Auth\FinanceRoster;
use App\Services\Auth\DingTalkClosedAccess;
use App\Services\Auth\FinanceRoster;
use App\Services\Auth\SupplierPortalAccess;
use Closure;
use Throwable;
@ -15,7 +16,11 @@ class LogoutIfDingTalkBindingRevoked
$identityId = $request->session()->get('dingtalk_identity_id');
$user = $request->user();
if (! $identityId) {
if (! $user || ! in_array($user->created_from, ['dingtalk_roster', 'dingtalk_roster_limited'], true)) {
if (! $user || ! in_array($user->created_from, [
'dingtalk_roster',
'dingtalk_roster_limited',
SupplierPortalAccess::CREATED_FROM,
], true)) {
return $next($request);
}
// A password reset must not bypass HR eligibility for provisioned
@ -32,7 +37,9 @@ class LogoutIfDingTalkBindingRevoked
return $next($request);
}
try {
$eligible = app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id);
$eligible = SupplierPortalAccess::isSupplierUser($user)
? app(SupplierPortalAccess::class)->revalidate($user) !== null
: app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id);
} catch (Throwable) {
// Retain the session during an HR outage, but do not serve data
// using an expired authorization decision.
@ -46,7 +53,11 @@ class LogoutIfDingTalkBindingRevoked
}
app(DingTalkClosedAccess::class)->audit('session_revoked', 'denied', $user?->id, null, $identity?->union_id, $request, [
'reason' => ! $user?->enabled ? 'local_account_ineligible' : (! $identity ? 'binding_revoked' : 'outside_finance_roster'),
'reason' => ! $user?->enabled
? 'local_account_ineligible'
: (! $identity
? 'binding_revoked'
: (SupplierPortalAccess::isSupplierUser($user) ? 'outside_supplier_roster' : 'outside_finance_roster')),
]);
auth()->logout();
$request->session()->invalidate();

View 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 . ' <原参数>。网页容器未执行本次导入。'
);
}
}
}

View File

@ -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)) {

View 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);
}
}

View File

@ -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');

View File

@ -14,6 +14,7 @@ class CourierQuoteVersion extends Model
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
'carrier_name', 'courier_expcode', 'billing_rules', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json',
'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by',
'billing_structure', 'billing_rows',
];
public const STATUS_APPROVED = 'approved';
@ -26,6 +27,8 @@ class CourierQuoteVersion extends Model
'effective_date' => 'date',
'end_date' => 'date',
'voided_at' => 'datetime',
'billing_structure' => 'array',
'billing_rows' => 'array',
];
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
@ -56,6 +59,14 @@ class CourierQuoteVersion extends Model
: $this->effective_date->toDateString() . ' 起';
}
/** 是否已有可用于运费核算的结构化计费规则。 */
public function hasBillingRules(): bool
{
$mode = $this->billing_structure['mode'] ?? null;
return is_string($mode) && $mode !== 'other' && is_array($this->billing_rows) && $this->billing_rows !== [];
}
public function isVoided(): bool
{
return $this->status === self::STATUS_VOIDED;

View File

@ -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 => [

View File

@ -28,9 +28,13 @@ class DingTalkClosedAccess
}
$roster = app(FinanceRoster::class)->findEligible((string) ($profile['dingtalk_user_id'] ?? ''));
if (! $roster) {
$supplierRoster = $roster ? null : app(SupplierPortalAccess::class)->resolve(
(string) ($profile['dingtalk_user_id'] ?? ''),
(string) ($profile['union_id'] ?? ''),
);
if (! $roster && ! $supplierRoster) {
$this->audit('login_denied', 'denied', null, null, $profile['union_id'] ?? null, $request, [
'reason' => 'outside_finance_roster',
'reason' => 'outside_authorized_roster',
]);
$request->attributes->set('dingtalk_denial', 'outside_roster');
return null;
@ -49,6 +53,23 @@ class DingTalkClosedAccess
return null;
}
if (SupplierPortalAccess::isSupplierUser($identity->user)) {
$company = $this->enabledCompany($identity->user);
if (! $supplierRoster || ! $company) {
$this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [
'reason' => 'outside_supplier_roster',
]);
return null;
}
app(SupplierPortalAccess::class)->bind($identity->user, (int) $company->id, $supplierRoster);
} elseif (! $roster) {
$this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [
'reason' => 'outside_finance_roster',
]);
return null;
}
$identity->forceFill([
'open_id' => $profile['open_id'] ?: $identity->open_id,
@ -69,7 +90,7 @@ class DingTalkClosedAccess
return null;
}
return DB::transaction(function () use ($profile, $roster, $request): ?DingTalkIdentity {
return DB::transaction(function () use ($profile, $roster, $supplierRoster, $request): ?DingTalkIdentity {
// Serialize first-time provisioning on the target company. Both the
// account and unique identity are committed together, including retries.
$companies = Company::enabled()
@ -92,10 +113,11 @@ class DingTalkClosedAccess
return null;
}
$roleName = FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id'])
$supplierPortal = $supplierRoster !== null;
$roleName = ! $supplierPortal && FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id'])
? SystemAccess::SUPER_ADMIN : SystemAccess::USER;
$role = Role::where('name', $roleName)->firstOrFail();
$email = $this->normalizeEmail($profile['email'] ?? null);
$email = $supplierPortal ? null : $this->normalizeEmail($profile['email'] ?? null);
if (! $email || User::withTrashed()->where('email', $email)->exists()) {
$email = 'ding-'.hash('sha256', $profile['union_id']).'@dingtalk.invalid';
}
@ -104,21 +126,25 @@ class DingTalkClosedAccess
return null;
}
$account = User::create([
'name' => Str::limit($profile['nickname'] ?: $roster['display_name'], 191, ''),
'name' => Str::limit($profile['nickname'] ?: ($roster['display_name'] ?? '供应商人员'), 191, ''),
'email' => $email,
'password' => Str::random(64),
'locale' => 'zh-CN',
'enabled' => true,
'landing_page' => 'dashboard',
'landing_page' => $supplierPortal ? 'business.supplier-portal.home' : 'dashboard',
'created_by' => 0,
'created_from' => FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id'])
? 'dingtalk_roster_limited' : 'dingtalk_roster',
'created_from' => $supplierPortal
? SupplierPortalAccess::CREATED_FROM
: (FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id'])
? 'dingtalk_roster_limited' : 'dingtalk_roster'),
]);
$account->roles()->attach($role->id);
// Explicit designation controls the role; ordinary roster users get shell access.
$account->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
$account->companies()->attach($companies->first()->id);
if ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) {
if ($supplierPortal) {
app(SupplierPortalAccess::class)->bind($account, (int) $companies->first()->id, $supplierRoster);
} elseif ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) {
Contact::create([
'company_id' => $companies->first()->id, 'type' => Contact::EMPLOYEE_TYPE,
'user_id' => $account->id, 'name' => $account->name, 'email' => $email,
@ -131,7 +157,12 @@ class DingTalkClosedAccess
'open_id' => $profile['open_id'], 'dingtalk_user_id' => $profile['dingtalk_user_id'],
'nickname' => $profile['nickname'], 'bound_at' => now(),
]);
$this->upsertRequest($profile, DingTalkBindingRequest::APPROVED, 'finance_roster_provisioned', $account->id);
$this->upsertRequest(
$profile,
DingTalkBindingRequest::APPROVED,
$supplierPortal ? 'supplier_roster_provisioned' : 'finance_roster_provisioned',
$account->id,
);
$this->audit('account_auto_created', 'success', $account->id, null, $profile['union_id'], $request);
return $identity->load('user');
}, 3);
@ -165,9 +196,12 @@ class DingTalkClosedAccess
private function isEligible(?User $user): bool
{
return $user
&& $user->enabled
&& $user->withoutEvents(fn () => $user->companies()->enabled()->exists());
return $user && $user->enabled && $this->enabledCompany($user) !== null;
}
private function enabledCompany(User $user): ?Company
{
return $user->withoutEvents(fn () => $user->companies()->enabled()->first());
}
private function upsertRequest(array $profile, string $status, string $reason, ?int $matchedUserId): void

View 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));
}
}

View File

@ -0,0 +1,157 @@
<?php
namespace App\Support;
use App\Models\Business\CourierQuoteVersion;
use DateTimeInterface;
use Illuminate\Support\Collection;
/**
* 按「快递编码 + 发货日期」找到当天生效的报价版本,用结构化计费规则算一票的运费。
*
* 计费重量 = max(实重, 体积重, 起算重量) 再按取整方式处理;超过限重不计算。
*
* 生效版本的判断与报价管理页一致(CourierRequoteService::resolveAt:未作废、已审核或已签署归档、
* 执行期间覆盖发货日期,短期变价优先)。没有结构化规则的版本(按体积、按条码等特殊计费)返回原因,交人工核算。
*/
class CourierQuoteFreightCalculator
{
/** @var array<string,Collection<int,CourierQuoteVersion>> */
private array $versions = [];
/**
* @param string $courierExpcode 快递编码;没有编码的存量报价可传快递名称(如「沐野竹木顺丰」)
* @return array{
* version_code:?string,company_name:?string,region:?string,gross_weight:float,volume_weight:?float,
* billable_weight:?float,base_amount:?float,surcharge:float,amount:?float,formula:?string,reason:?string
* }
*/
public function calculate(
string $courierExpcode,
DateTimeInterface $shipDate,
string $province,
float $grossWeight,
?float $length = null,
?float $width = null,
?float $height = null,
?string $city = null,
?string $quoteType = null,
): array {
$result = [
'version_code' => null, 'company_name' => null, 'region' => null, 'gross_weight' => $grossWeight,
'volume_weight' => null, 'billable_weight' => null, 'base_amount' => null, 'surcharge' => 0.0,
'amount' => null, 'formula' => null, 'reason' => null,
];
if (! is_finite($grossWeight) || $grossWeight < 0
|| collect([$length, $width, $height])->contains(static fn ($v) => $v !== null && (! is_finite($v) || $v < 0))) {
return ['reason' => '重量和尺寸必须是有效非负数字'] + $result;
}
$version = $this->versionAt(trim($courierExpcode), $shipDate, $quoteType);
if ($version === null) {
return ['reason' => '发货日期没有生效的报价'] + $result;
}
$result['version_code'] = $version->version_code;
$result['company_name'] = $version->company_name;
if (! $version->hasBillingRules()) {
return ['reason' => '该报价为特殊计费或尚未设置计费规则,需人工核算'] + $result;
}
$structure = (array) $version->billing_structure;
$row = $this->matchRow((array) $version->billing_rows, $province, $city);
if ($row === null) {
return ['reason' => '报价未覆盖目的地「' . trim($province . ' ' . $city) . '」或该地区不发'] + $result;
}
$result['region'] = (string) $row['region'];
$divisor = (float) ($structure['volumetric_divisor'] ?? 0);
$volume = $divisor > 0 && $length > 0 && $width > 0 && $height > 0 ? round($length * $width * $height / $divisor, 4) : null;
$settlement = max($grossWeight, (float) $volume);
$result['volume_weight'] = $volume;
if ($settlement <= 0) {
return ['reason' => '缺少重量,无法计算运费'] + $result;
}
$limit = (float) ($structure['weight_limit'] ?? 0);
if ($limit > 0 && $settlement > $limit + 1e-9) {
return ['reason' => '超出限重 ' . CourierQuoteStructure::kg($limit) . 'kg'] + $result;
}
// 起算重量:不足按起算重量计(如菏泽顺帆顺丰不足 5kg 按 5kg)
$minimum = (float) ($structure['min_weight'] ?? 0);
$billable = CourierQuoteStructure::roundWeight(max($settlement, $minimum), (string) ($structure['rounding'] ?? 'none'));
$result['billable_weight'] = $billable;
$freight = CourierQuoteStructure::freight($structure, $row, $billable);
if ($freight['amount'] === null) {
return ['reason' => $freight['reason']] + $result;
}
$surcharge = (float) ($structure['per_ticket_surcharge'] ?? 0);
$result['base_amount'] = round($freight['amount'], 2);
$result['surcharge'] = $surcharge;
$result['amount'] = round($freight['amount'] + $surcharge, 2);
$result['formula'] = $freight['formula'] . ($surcharge > 0 ? '+每票' . CourierQuoteStructure::kg($surcharge) : '');
return $result;
}
/** 省份名统一:去掉省/市/自治区后缀,黑龙→黑龙江、内蒙→内蒙古。 */
public static function regionKey(string $name): string
{
$text = (string) preg_replace('/\s+/u', '', $name);
$text = (string) preg_replace('/(维吾尔自治区|壮族自治区|回族自治区|自治区|特别行政区|省|市)$/u', '', $text);
return match ($text) {
'黑龙' => '黑龙江',
'内蒙' => '内蒙古',
default => $text,
};
}
private function versionAt(string $courierExpcode, DateTimeInterface $date, ?string $quoteType): ?CourierQuoteVersion
{
if ($courierExpcode === '') {
return null;
}
// 先按快递编码;部分存量版本没有编码,按快递名称(与报价管理页的分组口径一致)
$cacheKey = company_id() . ':' . $courierExpcode;
$versions = $this->versions[$cacheKey] ??= CourierQuoteVersion::query()
->with(['signature', 'change'])
->where(static fn ($query) => $query->where('courier_expcode', $courierExpcode)
->orWhere(static fn ($q) => $q->where(static fn ($e) => $e->whereNull('courier_expcode')->orWhere('courier_expcode', ''))
->where('company_name', $courierExpcode)))
->get();
return CourierRequoteService::resolveAt(
$quoteType === null ? $versions : $versions->where('quote_type', $quoteType),
$date,
);
}
/**
* 先按城市匹配单列的城市行(如「深圳(广东)」),再按省份匹配。
*
* @param list<array<string,mixed>> $rows
* @return array<string,mixed>|null
*/
private function matchRow(array $rows, string $province, ?string $city): ?array
{
$cityKey = self::regionKey((string) $city);
if ($cityKey !== '') {
foreach ($rows as $row) {
if (is_array($row) && preg_match('/^(.+?)[((](.+?)[))]$/u', trim((string) ($row['region'] ?? '')), $parts)
&& self::regionKey($parts[1]) === $cityKey && self::regionKey($parts[2]) === self::regionKey($province)) {
return $row;
}
}
}
$key = self::regionKey($province);
foreach ($rows as $row) {
if (is_array($row) && self::regionKey((string) ($row['region'] ?? '')) === $key) {
return $row;
}
}
return null;
}
}

View File

@ -40,6 +40,13 @@ final class CourierQuoteStructure
'round' => '四舍五入(如 4.4kg 按 4kg,4.5kg 按 5kg)',
];
/**
* 分段计费:不放进 MODES(入驻表单不提供),只用于存量报价导入。
* 每个省份行为 {region, segments:[{lo, hi, type: flat|waybill|first_add, price|base, rate, fw}]},
* 重量落在 (lo, hi](第一段含 0)就按该段计算;hi 为 null 表示不封顶。
*/
public const SEGMENTS = 'segments';
public const MAX_BANDS = 12;
public const MAX_TIERS = 6;
@ -272,10 +279,120 @@ final class CourierQuoteStructure
? "首重{$fw}kg价+(重量-{$fw})*续重"
: '面单+重量*费率') . $tierNote,
'other' => '特殊计费,见附件报价表',
self::SEGMENTS => '分段计费:每个重量段单独设一口价、面单+费率或首重+续重(可表示累进计费)',
default => '面单+重量*费率' . $tierNote,
};
}
/**
* 按结构化报价计算一票的基础运费(不含计泡、限重、附加费,这些由调用方先处理)。
*
* 与 describe() 口径一致:费率分段按「总重量所在区间」取费率,区间上限含本数;
* 一口价/费率为空表示该重量段不发。$weight 应为已取整的计费重量。
*
* @param array<string,mixed> $structure
* @param array<string,mixed> $row
* @return array{amount:?float,formula:?string,reason:?string}
*/
public static function freight(array $structure, array $row, float $weight): array
{
$mode = (string) ($structure['mode'] ?? 'waybill_rate');
$none = static fn (string $reason): array => ['amount' => null, 'formula' => null, 'reason' => $reason];
$ok = static fn (float $amount, string $formula): array => ['amount' => round($amount, 4), 'formula' => $formula, 'reason' => null];
if (! is_finite($weight) || $weight <= 0) {
return $none('缺少有效重量,无法计算运费');
}
$w = self::kg($weight);
$within = static fn (float $value, float $edge): bool => $value <= $edge + 1e-9;
if ($mode === self::SEGMENTS) {
foreach ((array) ($row['segments'] ?? []) as $segment) {
$lo = (float) ($segment['lo'] ?? 0);
$hi = isset($segment['hi']) ? (float) $segment['hi'] : INF;
if (! (($weight > $lo + 1e-9 || $lo <= 0.0) && $within($weight, $hi))) {
continue;
}
$type = (string) ($segment['type'] ?? '');
if (! in_array($type, ['flat', 'waybill', 'first_add'], true)) {
return $none('不支持的分段计费方式,需人工核算');
}
if ($type === 'flat') {
return isset($segment['price']) ? $ok((float) $segment['price'], '一口价' . self::kg((float) $segment['price'])) : $none('该重量段不发');
}
if (! isset($segment['base'], $segment['rate'])) {
return $none('该重量段不发');
}
$base = (float) $segment['base'];
$rate = (float) $segment['rate'];
if ($type === 'first_add') {
$fw = (float) ($segment['fw'] ?? 0);
return $within($weight, $fw)
? $ok($base, '首重' . self::kg($base))
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
}
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
}
return $none('超出报价重量范围');
}
if (! in_array($mode, ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then'], true)) {
return $none('特殊计费,需人工核算');
}
$bands = array_map('floatval', array_values((array) ($structure['flat_bands'] ?? [])));
$flat = array_values((array) ($row['flat'] ?? []));
if (in_array($mode, ['flat_bands', 'flat_then'], true) && $bands !== [] && ($mode === 'flat_bands' || $within($weight, end($bands)))) {
foreach ($bands as $index => $edge) {
if ($within($weight, $edge)) {
$price = $flat[$index] ?? null;
return is_numeric($price) ? $ok((float) $price, '≤' . self::kg($edge) . 'kg 一口价' . self::kg((float) $price)) : $none('该重量段不发');
}
}
return $none('超出报价重量范围');
}
$tiers = array_map('floatval', array_values((array) ($structure['rate_tiers'] ?? [])));
$index = count($tiers);
foreach ($tiers as $i => $edge) {
if ($within($weight, $edge)) {
$index = $i;
break;
}
}
$rate = $row['rates'][$index] ?? null;
$base = $row['base'] ?? null;
if (! is_numeric($rate) || ! is_numeric($base)) {
return $none('该重量段不发');
}
$rate = (float) $rate;
$base = (float) $base;
$then = $mode === 'flat_then' ? (string) ($structure['then_mode'] ?? 'waybill_rate') : $mode;
if ($then === 'first_additional') {
$fw = (float) ($structure['first_weight'] ?? 0);
return $within($weight, $fw)
? $ok($base, '首重' . self::kg($base))
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
}
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
}
/** 按取整方式得到计费重量:ceil 向上取整,round 四舍五入(4.5 进为 5)。 */
public static function roundWeight(float $weight, string $rounding): float
{
return match ($rounding) {
'ceil' => (float) ceil(round($weight, 6)),
'round' => (float) floor($weight + 0.5),
default => $weight,
};
}
public static function usesRates(string $mode): bool
{
return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true);

View File

@ -142,6 +142,23 @@ final class QuoteManagementSnapshot
return $companyName !== '' ? $companyName : $name;
}
/** 「查看」面板的计费规则一行:计费方式 + 取整/计泡/限重/每票加收。 */
private function billingSummary(CourierQuoteVersion $version): string
{
$structure = (array) $version->billing_structure;
$kg = static fn ($value): string => CourierQuoteStructure::kg((float) $value);
$parts = array_filter([
CourierQuoteStructure::describe($structure),
($structure['rounding'] ?? 'none') !== 'none' ? (CourierQuoteStructure::ROUNDING[$structure['rounding']] ?? null) : null,
filled($structure['min_weight'] ?? null) ? '不足' . $kg($structure['min_weight']) . 'kg按' . $kg($structure['min_weight']) . 'kg计' : null,
filled($structure['volumetric_divisor'] ?? null) ? '计泡 ' . $structure['volumetric_divisor'] : null,
filled($structure['weight_limit'] ?? null) ? '限重 ' . $kg($structure['weight_limit']) . 'kg' : null,
filled($structure['per_ticket_surcharge'] ?? null) ? '每票加收 ' . $kg($structure['per_ticket_surcharge']) . ' 元' : null,
]);
return implode(';', $parts) . '(' . count((array) $version->billing_rows) . ' 个地区)';
}
private function normalizeVersion(CourierQuoteVersion $version): array
{
$values = $this->decodeMatrix($version->values_json);
@ -155,6 +172,7 @@ final class QuoteManagementSnapshot
'company_name' => $version->company_name,
'courier_expcode' => $version->courier_expcode,
'billing_rules' => $version->billing_rules,
'billing_summary' => $version->hasBillingRules() ? $this->billingSummary($version) : null,
'carrier' => $version->carrier_name,
'quote_type' => $version->quote_type,
'effective_date' => $version->effective_date?->toDateString(),

View File

@ -21,6 +21,10 @@ class FinanceLedgerAccess
return false;
}
if (SystemAccess::roleName($user) === SystemAccess::ADMIN) {
return true;
}
$roles = array_values(array_filter(array_map(
'trim',
explode(',', (string) config('finance_ledger.mapping_editor_roles', '财务台账管理员')),
@ -28,4 +32,17 @@ class FinanceLedgerAccess
return in_array(SystemAccess::roleName($user), $roles, true);
}
/**
* 账户状态、账户类型与账户映射使用同一组财务台账编辑角色。
*/
public static function canEditAccountStatus(?User $user = null): bool
{
return self::canEditMapping($user);
}
public static function canEditAccountType(?User $user = null): bool
{
return self::canEditMapping($user);
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Utilities;
use Illuminate\Database\Query\Builder;
class FinanceLedgerAccountShouldKeep
{
public const YES = '是';
public const NO = '否';
public const OPTIONS = [self::YES, self::NO];
public static function normalize(?string $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
$compact = mb_strtoupper((string) preg_replace('/\s+/u', '', $value));
if (in_array($compact, ['是', '保留', '需要保留', '需要', 'Y', 'YES', 'TRUE', '1'], true)) {
return self::YES;
}
if (in_array($compact, ['否', '不保留', '不需要保留', '不需要', 'N', 'NO', 'FALSE', '0'], true)) {
return self::NO;
}
return $value;
}
public static function display(?string $explicitValue, ?string $companyPlan): string
{
$normalized = self::normalize($explicitValue);
if ($normalized !== '') {
return $normalized;
}
$plan = trim((string) $companyPlan);
if ($plan === '') {
return '';
}
return str_contains($plan, '保留') ? self::YES : self::NO;
}
public static function applyUnretainedCondition(
Builder $query,
string $explicitColumn = 'account_should_keep',
string $companyPlanColumn = 'company_plan',
): Builder {
return $query->where(function (Builder $keep) use ($explicitColumn, $companyPlanColumn): void {
$keep->where($explicitColumn, self::NO)
->orWhere(function (Builder $inferred) use ($explicitColumn, $companyPlanColumn): void {
$inferred->where(function (Builder $blank) use ($explicitColumn): void {
$blank->whereNull($explicitColumn)->orWhere($explicitColumn, '');
})
->whereNotNull($companyPlanColumn)
->where($companyPlanColumn, '<>', '')
->where($companyPlanColumn, 'not like', '%保留%');
});
});
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Utilities;
final class FinanceLedgerAccountStatus
{
public const NORMAL = 'normal';
public const ONLINE_BANKING_ABNORMAL = 'online_banking_abnormal';
public const LIMITED = 'limited';
public const BUSINESS_DEREGISTERED = 'business_deregistered';
public const OPTIONS = [
self::NORMAL => '正常',
self::ONLINE_BANKING_ABNORMAL => '网银异常',
self::LIMITED => '限额',
self::BUSINESS_DEREGISTERED => '工商注销',
];
public static function normalize(?string $status): string
{
$status = trim((string) $status);
return array_key_exists($status, self::OPTIONS) ? $status : self::NORMAL;
}
public static function label(?string $status): string
{
return self::OPTIONS[self::normalize($status)];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Utilities;
final class FinanceLedgerAccountType
{
public const ALIPAY = '支付宝';
public const BANK_ACCOUNT_TYPES = ['公户', '个人卡', '网商银行'];
public const DEFAULT_OPTIONS = [
'个人卡',
'公户',
self::ALIPAY,
'网商银行',
];
public static function options(array ...$groups): array
{
$types = self::DEFAULT_OPTIONS;
foreach ($groups as $group) {
foreach ($group as $type) {
$type = trim((string) $type);
if ($type !== '') {
$types[] = $type;
}
}
}
$types = array_values(array_unique($types));
sort($types, SORT_NATURAL | SORT_FLAG_CASE);
return $types;
}
public static function last4(?string $accountType, ?string $accountNo, ?string $fallback = null): string
{
$type = trim((string) $accountType);
if ($type === self::ALIPAY) {
return '/';
}
if (in_array($type, self::BANK_ACCOUNT_TYPES, true)) {
$digits = preg_replace('/\D+/', '', (string) $accountNo);
if (strlen((string) $digits) >= 4) {
return substr((string) $digits, -4);
}
}
$fallback = trim((string) $fallback);
return $fallback !== '' ? $fallback : '/';
}
}

View File

@ -20,6 +20,7 @@ class SidebarMenu
['workspace', 'overview', '经营总览', 'monitoring'],
]],
['title' => '经营报表', 'items' => [
['financial-operations', 'ledger', '财务台账', 'menu_book'],
['company-management', 'subject-operating-report', '主体维度经营报表', 'domain'],
['company-management', 'company-cost-arrangement', '公司成本安排', 'account_balance_wallet'],
['company-management', 'tax-forecast', '税金预测表', 'calculate'],

View File

@ -5,11 +5,14 @@ namespace App\Utilities;
use App\Models\Auth\User;
use App\Models\Auth\UserAccessGroup;
use App\Services\Auth\FinanceRoster;
use App\Services\Auth\SupplierPortalAccess;
class SidebarPermissions
{
private const LEGACY_PAGE_KEYS = [
'refunds/small-payment-work-orders' => 'financial-operations/small-payment-work-orders',
'financial-ledger' => 'financial-operations/ledger',
'financial-ledger/ledger' => 'financial-operations/ledger',
];
private const FINAL_SECTION_KEYS = [
@ -43,6 +46,10 @@ class SidebarPermissions
public static function allowedKeys(User $user): ?array
{
// Limited identities cannot inherit full navigation from roles, groups or user overrides.
if (SupplierPortalAccess::isSupplierUser($user)) {
return [SupplierPortalAccess::PAGE_KEY];
}
if (self::hasLimitedDingTalkAccess($user)) {
return config('dingtalk.limited_login_page_keys', []);
}
@ -75,7 +82,8 @@ class SidebarPermissions
public static function hasLimitedDingTalkAccess(User $user): bool
{
return $user->created_from === 'dingtalk_roster_limited'
return SupplierPortalAccess::isSupplierUser($user)
|| $user->created_from === 'dingtalk_roster_limited'
|| (config('dingtalk.limited_login_user_ids', []) !== []
&& FinanceRoster::isLimitedLoginUser((string) $user->dingtalkIdentity?->dingtalk_user_id));
}

View File

@ -94,6 +94,9 @@ services:
pull_policy: never
container_name: caiwu_app
restart: unless-stopped
# Web requests and small scheduled jobs have their own memory boundary.
mem_limit: 2g
memswap_limit: 2g
depends_on:
db:
condition: service_healthy
@ -123,7 +126,7 @@ services:
retries: 10
start_period: 45s
# Heavy CLI jobs use a separate cgroup, not Apache's unbounded app container.
# Heavy CLI jobs use a separate cgroup from Apache.
batch:
image: caiwu_akaunting:${IMAGE_TAG:?IMAGE_TAG is required}
pull_policy: never
@ -133,6 +136,7 @@ services:
command: [list]
restart: "no"
environment:
FINANCE_BATCH_CONTAINER: "1"
SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION: business_mirror
JST_DB_CONNECTION: business_mirror
env_file:
@ -142,8 +146,8 @@ services:
- ./runtime/.env:/var/www/html/.env:ro
- caiwu_storage:/var/www/html/storage
cpus: "1.00"
mem_limit: 3g
memswap_limit: 3g
mem_limit: ${FINANCE_BATCH_MEMORY_LIMIT:-3g}
memswap_limit: ${FINANCE_BATCH_MEMORY_LIMIT:-3g}
healthcheck:
disable: true
logging:

View File

@ -881,6 +881,7 @@ $navigation = [
['slug' => 'bookkeeping-details', 'title' => '记账明细'],
['slug' => 'non-operating-expenses', 'title' => '营业外支出'],
['slug' => 'financial-analysis', 'title' => '财务经营分析表'],
['slug' => 'ledger', 'title' => '财务台账', 'show_in_section_menu' => false],
],
],
[
@ -943,14 +944,6 @@ $navigation = [
['slug' => 'account-mapping', 'title' => '科目映射表'],
],
],
[
'slug' => 'financial-ledger',
'title' => '财务台账',
'icon' => 'menu_book',
'pages' => [
['slug' => 'ledger', 'title' => '财务台账'],
],
],
[
'slug' => 'data-monitoring',
'title' => '数据监控',

7
config/finance_batch.php Normal file
View File

@ -0,0 +1,7 @@
<?php
return [
'container' => (bool) env('FINANCE_BATCH_CONTAINER', false),
// The host runner and application share this registry.
'import_commands' => json_decode(file_get_contents(base_path('deploy/finance-batch-imports.json')), true, 512, JSON_THROW_ON_ERROR),
];

View File

@ -12,6 +12,9 @@ return [
// 动账明细的唯一上游来源;本地调试可切换到本地快照导入。
'source' => env('FINANCE_LEDGER_RPT_SOURCE', 'operations_replica'),
// 台账上游查询允许更长的超时;普通页面仍使用共享连接的 15 秒。
'query_timeout' => (int) env('FINANCE_LEDGER_QUERY_TIMEOUT', 120),
// 具备账户映射写权限的角色;超级管理员始终可写。
'mapping_editor_roles' => env('FINANCE_LEDGER_MAPPING_EDITOR_ROLES', '财务台账管理员'),

View File

@ -0,0 +1,143 @@
<?php
/*
|--------------------------------------------------------------------------
| 订单售后费用明细(聚水潭「订单售后费用数据」导出)
|--------------------------------------------------------------------------
|
| 聚水潭导出文件是中文表头,这里把表头映射成页面字段键,导入时按这个映射入库。
| 未列出的表头会忽略;新增表头时补在这里即可。
*/
return [
'headers' => [
'内部订单号' => 'internal_order_no',
'线上订单号' => 'online_order_no',
'子订单号' => 'sub_order_no',
'售后单号' => 'after_sales_no',
'支付时间' => 'pay_time',
'订单完成时间' => 'order_complete_time',
'发货时间' => 'send_time',
'订单时间' => 'order_time',
'店铺名称' => 'shop_name',
'店铺编码' => 'shop_code',
'商品编码' => 'product_code',
'款式编码' => 'style_code',
'款式编码(参考)' => 'style_code',
'店铺商品编码' => 'shop_product_code',
'店铺款式编码' => 'shop_style_code',
'链接' => 'product_link',
"链接\u{3000}" => 'product_link',
'品牌' => 'brand',
'分类' => 'category',
'虚拟分类' => 'virtual_category',
'颜色及规格' => 'color_spec',
'商品名称' => 'product_name',
'唯一码' => 'unique_code',
'商品数量' => 'product_quantity',
'商品金额' => 'product_amount',
'商品成本' => 'product_cost',
'成本来源' => 'cost_source',
'其中:红冲订单成本' => 'reversal_order_cost',
'手动调整收入' => 'manual_adjust_income',
'手动调整成本' => 'manual_adjust_cost',
'商家优惠' => 'merchant_discount',
'运费收入' => 'freight_income',
'付款金额' => 'payment_amount',
'其中:平台优惠' => 'platform_discount',
'其中:红冲订单金额' => 'reversal_order_amount',
'实发金额' => 'actual_shipped_amount',
'实发数量' => 'actual_shipped_quantity',
'实发成本' => 'actual_shipped_cost',
'订单类型' => 'order_type',
'登记时间' => 'registration_time',
'确认时间' => 'confirmation_time',
'进仓时间' => 'warehouse_entry_time',
'线上商品名称' => 'online_product_name',
'发货前申请数量' => 'pre_application_quantity',
'发货前申请金额' => 'pre_application_amount',
'发货前申请成本' => 'pre_application_cost',
'发货前退回数量' => 'pre_return_quantity',
'发货前退回金额' => 'pre_return_amount',
'发货前退回成本' => 'pre_return_cost',
'发货前退款金额' => 'pre_refund_amount',
'发货前退款成本' => 'pre_refund_cost',
'发货前退平台补贴' => 'pre_platform_subsidy',
'发货后申请数量' => 'post_application_quantity',
'发货后申请金额' => 'post_application_amount',
'发货后申请成本' => 'post_application_cost',
'发货后退回数量' => 'post_return_quantity',
'发货后退回金额' => 'post_return_amount',
'发货后退回成本' => 'post_return_cost',
'发货后退款金额' => 'post_refund_amount',
'发货后退款成本' => 'post_refund_cost',
'发货后退平台补贴' => 'post_platform_subsidy',
'其中:次品商品数量' => 'defective_quantity',
'退款类型' => 'refund_type',
'问题类型' => 'question_type',
'明细退货类型' => 'detail_return_type',
'分类单' => 'classification_order',
'赠品' => 'is_gift',
'组合装' => 'combine_type',
'订单标签' => 'order_tags',
'售后单标签' => 'after_sales_tags',
'商品标签' => 'item_tags',
'发货仓编号' => 'shipping_warehouse_code',
'发货仓库' => 'shipping_warehouse',
'运营云仓' => 'operation_cloud_warehouse',
'收货仓编号' => 'receiving_warehouse_code',
'收货仓' => 'receiving_warehouse',
'供销商编号' => 'supplier_code',
'供销商' => 'supplier_name',
'快递单号' => 'express_no',
'快递公司' => 'express_company',
'省' => 'province',
'市' => 'city',
'业务员' => 'salesperson',
'线下客户编号' => 'offline_customer_code',
'线下客户编码' => 'offline_customer_code',
'线下客户名称' => 'offline_customer_name',
'500112冲减收入' => 'account_500112',
'60031erp快递费' => 'account_60031',
'800101其他服务费' => 'account_800101',
'800103其他活动费用' => 'account_800103',
'800104违约赔付' => 'account_800104',
'800105平台佣金' => 'account_800105',
'800105佣金' => 'account_800105',
'800106保证金' => 'account_800106',
'800107推广充值' => 'account_800107',
'800108品牌新享' => 'account_800108',
'800109淘宝客/多多进宝' => 'account_800109',
'800110运费险' => 'account_800110',
'800114公益宝贝' => 'account_800114',
'8001201直通车/钻展/快车等' => 'account_8001201',
'8001202推广红包冲减' => 'account_8001202',
'800122售后补偿' => 'account_800122',
'800125红包' => 'account_800125',
'800127退货运费' => 'account_800127',
'800129退款' => 'account_800129',
'800130订单' => 'account_800130',
'800132其他费用' => 'account_800132',
'800133品牌新享-收入' => 'account_800133',
'800304售后补偿-聚工单' => 'account_800304',
'800501种菜佣金' => 'account_800501',
'800503发空包/赠品费用' => 'account_800503',
],
'number_keys' => [
'product_quantity', 'product_amount', 'product_cost', 'reversal_order_cost',
'manual_adjust_income', 'manual_adjust_cost', 'merchant_discount', 'freight_income',
'payment_amount', 'platform_discount', 'reversal_order_amount', 'actual_shipped_amount',
'actual_shipped_quantity', 'actual_shipped_cost', 'pre_application_quantity',
'pre_application_amount', 'pre_application_cost', 'pre_return_quantity', 'pre_return_amount',
'pre_return_cost', 'pre_refund_amount', 'pre_refund_cost', 'pre_platform_subsidy',
'post_application_quantity', 'post_application_amount', 'post_application_cost',
'post_return_quantity', 'post_return_amount', 'post_return_cost', 'post_refund_amount',
'post_refund_cost', 'post_platform_subsidy', 'defective_quantity',
'account_500112', 'account_60031', 'account_800101', 'account_800103', 'account_800104',
'account_800105', 'account_800106', 'account_800107', 'account_800108', 'account_800109',
'account_800110', 'account_800114', 'account_8001201', 'account_8001202', 'account_800122',
'account_800125', 'account_800127', 'account_800129', 'account_800130', 'account_800132',
'account_800133', 'account_800304', 'account_800501', 'account_800503',
],
];

View File

@ -325,9 +325,14 @@ return [
'keys' => ['Id'],
'watermark' => 'updateTime',
'indexes' => [
['Id'],
['as_date', 'Id'],
['o_id', 'Id'],
['as_id', 'Id'],
['shop_id', 'outer_as_id(100)'],
['so_id(64)'],
['outer_as_id(64)'],
['as_date', 'Id', 'as_id', 'shop_site(300)'],
],
],
'operations_replica.rpt.jst_returnAndRefundItem' => [
@ -336,11 +341,14 @@ return [
'object' => 'jst_returnAndRefundItem',
'target' => 'ops_jst_return_refund_item',
'keys' => ['Id'],
// The replica can replace historical detail IDs. Append-only sync
// cannot discover those changes; reconcile in bounded weekly runs.
'full_reconcile_hours' => 168,
'indexes' => [
['as_id', 'Id'],
],
],
'operations_replica.rpt.jst_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
'operations_replica.rpt.jst_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime', 'indexes' => [['shop_id', 'Id']]],
// 淘系(天猫/淘宝)订单与售后:聚水潭单独存放在 qmcust 这套表里,
// 与 jst_Orders / jst_OrderItem / jst_returnAndRefundInfo 分开。
@ -383,6 +391,8 @@ return [
['as_id'],
['o_id', 'as_id'],
['as_date', 'as_id'],
['so_id(64)'],
['outer_as_id(64)'],
],
],
'operations_replica.rpt.jst_qmcust_refundlist_item' => [
@ -396,6 +406,34 @@ return [
['as_id', 'asi_id'],
],
],
// 聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」的原表。
// 列名是 undefined1..undefinedN,中文表头由 ..._schemas 表提供映射。
'operations_replica.rpt.jst_goods_profit_kpi_items_afs' => [
'source_key' => 'operations_replica',
'schema' => 'rpt',
'object' => 'jst_goods_profit_kpi_items_afs',
'target' => 'ops_jst_goods_profit_kpi_items_afs',
'keys' => ['id'],
'watermark' => 'updatedAt',
'batch_size' => 2000,
'max_batches_per_run' => 4,
// 160 多个 nvarchar 列,整行按 VARCHAR 建表会超过 InnoDB 行长度上限。
'wide_strings' => true,
'indexes' => [
['undefined8', 'undefined7'],
['undefined2'],
['undefined10'],
],
],
'operations_replica.rpt.jst_goods_profit_kpi_items_afs_schemas' => [
'source_key' => 'operations_replica',
'schema' => 'rpt',
'object' => 'jst_goods_profit_kpi_items_afs_schemas',
'target' => 'ops_jst_goods_profit_kpi_items_afs_schemas',
'keys' => ['id'],
'watermark' => 'id',
],
'operations_replica.rpt.jst_originalBillInfo' => [
'source_key' => 'operations_replica',
'schema' => 'rpt',

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 报价版本增加结构化计费规则,供运费核算使用;原始单元格(values_json)保留不动。
*
* billing_structure:{mode, flat_bands, then_mode, first_weight, rate_tiers, rounding, volumetric_divisor,
* weight_limit, min_weight(起算重量), per_ticket_surcharge, notes}
* billing_rows: [{region, flat, base, rates}] 或分段计费 [{region, segments}]
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return;
}
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
if (! Schema::hasColumn('business_courier_quote_versions', 'billing_structure')) {
$table->longText('billing_structure')->nullable();
}
if (! Schema::hasColumn('business_courier_quote_versions', 'billing_rows')) {
$table->longText('billing_rows')->nullable();
}
});
}
public function down(): void
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return;
}
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
foreach (['billing_structure', 'billing_rows'] as $column) {
if (Schema::hasColumn('business_courier_quote_versions', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 财务台账账户状态。
*
* 状态由财务人员在页面维护,独立于可被全量覆盖的 fin_accounts 基础表,
* 因此重新导入《银行当前余额》不会丢失。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_account_statuses')) {
$schema->create('fin_account_statuses', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('account_short', 100)->unique('fin_account_statuses_account_unique');
$table->string('status', 40)->default('normal');
$table->string('updated_by', 100)->nullable();
$table->timestamps();
$table->index(['status'], 'fin_account_statuses_status_index');
});
}
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('fin_account_statuses');
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 财务台账账户类型人工维护值。
*
* 基础表 fin_accounts 在导入时会被全量覆盖,因此人工调整后的类型单独保存。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_account_type_overrides')) {
$schema->create('fin_account_type_overrides', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('account_short', 100)->unique('fin_account_type_overrides_account_unique');
$table->string('account_type', 50)->nullable();
$table->string('updated_by', 100)->nullable();
$table->timestamps();
$table->index(['account_type'], 'fin_account_type_overrides_type_index');
});
}
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('fin_account_type_overrides');
}
};

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (app()->environment('testing') || config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
// 生产可能保留旧 SKU 级快照;使用独立源行基线,不修改或删除旧表。
if (! $schema->hasTable('jushuitan_cost_price_observation_baselines')) {
$schema->create('jushuitan_cost_price_observation_baselines', function (Blueprint $table): void {
$table->char('source_key', 64)->primary();
$table->string('sku_id', 255);
$table->decimal('cost_price', 20, 4);
$table->dateTime('captured_at', 6);
});
}
if (! $schema->hasTable('jushuitan_cost_price_observations')) {
$schema->create('jushuitan_cost_price_observations', function (Blueprint $table): void {
$table->char('id', 64)->primary();
$table->char('source_key', 64);
$table->string('sku_id', 255);
$table->decimal('before_price', 20, 4);
$table->decimal('after_price', 20, 4);
$table->dateTime('previous_observed_at', 6);
$table->dateTime('observed_at', 6);
$table->dateTime('source_modified_at', 6)->nullable();
$table->index(['source_key', 'observed_at', 'id'], 'jst_cost_observations_lookup');
});
}
}
public function down(): void
{
if (app()->environment('testing') || config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
$schema->dropIfExists('jushuitan_cost_price_observations');
$schema->dropIfExists('jushuitan_cost_price_observation_baselines');
}
};

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」导入明细。
*
* 聚水潭报表已按商品编码(组合装拆成组件)给出行和成本,这里原样保留每行字段,
* 订单售后费用明细页面在导入数据覆盖的店铺/日期范围内优先读这张表。
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('ops_after_sales_cost_rows', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('row_key', 40)->unique();
$table->string('shop_name', 191);
$table->string('shop_code', 64)->nullable();
$table->string('online_order_no', 64)->nullable();
$table->string('internal_order_no', 64)->nullable();
$table->string('product_code', 191)->nullable();
$table->dateTime('order_time')->nullable();
$table->decimal('product_quantity', 14, 2)->default(0);
$table->decimal('product_amount', 16, 2)->default(0);
$table->decimal('product_cost', 16, 2)->default(0);
$table->string('cost_source', 32)->nullable();
$table->string('combine_type', 32)->nullable();
$table->json('payload');
$table->timestamp('imported_at')->nullable();
$table->timestamps();
$table->index(['shop_name', 'order_time']);
$table->index('online_order_no');
$table->index('product_code');
});
}
public function down(): void
{
Schema::dropIfExists('ops_after_sales_cost_rows');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_accounts') || $schema->hasColumn('fin_accounts', 'account_should_keep')) {
return;
}
$schema->table('fin_accounts', function (Blueprint $table): void {
$table->string('account_should_keep', 20)->nullable()->after('company_plan');
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if ($schema->hasTable('fin_accounts') && $schema->hasColumn('fin_accounts', 'account_should_keep')) {
$schema->table('fin_accounts', function (Blueprint $table): void {
$table->dropColumn('account_should_keep');
});
}
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_accounts') || $schema->hasTable('fin_account_todos')) {
return;
}
$schema->create('fin_account_todos', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('account_short', 100)->unique('fin_account_todos_account_unique');
$table->text('abnormal_reason')->nullable();
$table->string('handling_method', 20)->nullable();
$table->string('owner_name', 100)->nullable();
$table->dateTime('started_at')->nullable();
$table->dateTime('completed_at')->nullable();
$table->string('current_progress', 100)->nullable();
$table->string('updated_by', 100)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('fin_account_todos');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('supplier_portal_identities')) {
return;
}
Schema::create('supplier_portal_identities', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('user_id')->unique();
$table->string('supplier_code', 191)->nullable();
$table->string('supplier_name', 191);
$table->unsignedBigInteger('dingtalk_group_id');
$table->string('dingtalk_group_name', 191);
$table->timestamp('last_verified_at')->nullable();
$table->timestamps();
$table->index(['company_id', 'supplier_name'], 'supplier_portal_company_name');
$table->index(['company_id', 'dingtalk_group_id'], 'supplier_portal_company_group');
});
}
public function down(): void
{
Schema::dropIfExists('supplier_portal_identities');
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_account_todos') || $schema->hasColumn('fin_account_todos', 'completion_status')) {
return;
}
$schema->table('fin_account_todos', function (Blueprint $table): void {
$table->string('completion_status', 20)->default('not_started')->after('current_progress');
$table->index(['completion_status'], 'fin_account_todos_completion_status_index');
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_account_todos') || ! $schema->hasColumn('fin_account_todos', 'completion_status')) {
return;
}
$schema->table('fin_account_todos', function (Blueprint $table): void {
$table->dropIndex('fin_account_todos_completion_status_index');
$table->dropColumn('completion_status');
});
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_accounts') || $schema->hasTable('fin_account_keep_todos')) {
return;
}
$schema->create('fin_account_keep_todos', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('account_short', 100)->unique('fin_account_keep_todos_account_unique');
$table->string('handling_method', 20)->nullable();
$table->string('owner_name', 100)->nullable();
$table->dateTime('started_at')->nullable();
$table->dateTime('completed_at')->nullable();
$table->string('current_progress', 20)->default('not_started');
$table->string('updated_by', 100)->nullable();
$table->timestamps();
$table->index(['current_progress'], 'fin_account_keep_todos_progress_index');
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('fin_account_keep_todos');
}
};

View File

@ -7,6 +7,11 @@ REMOTE_DIR="${3:-/opt/caiwuxitong}"
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
RUN_MIGRATIONS="${5:-0}"
KEEP_IMAGES="${6:-2}"
EXPECTED_PREVIOUS_SHA="${7:?Checked production revision is required; update the deployment scripts}"
DEPLOY_ACTOR="${8:-unknown}"
RELEASE_APP_URL="${9:-}"
RELEASE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$RELEASE_DIR/release-guard.sh"
IMAGE_NAME="caiwu_akaunting"
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
REGISTRY_API="http://127.0.0.1:5000/v2/caiwuxitong/app"
@ -65,6 +70,12 @@ if ! flock -n 9; then
exit 1
fi
RELEASE_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
RELEASE_RESULT=blocked
trap 'release_status=$?; caiwu_record_release_result "$release_status"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。
exec 8>/run/caiwu-upstream-sync.lock
echo "Waiting for active upstream synchronization before replacing the application container"
@ -74,7 +85,8 @@ if ! flock -w 1800 8; then
fi
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
caiwu_verify_release_base
RELEASE_RESULT=running
restore_previous_image() {
case "$PREVIOUS_IMAGE" in
@ -96,6 +108,8 @@ if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
exit 1
fi
caiwu_install_release_files
cd "$REMOTE_DIR"
export IMAGE_TAG RUN_MIGRATIONS
@ -196,6 +210,8 @@ CRON_TZ=Asia/Shanghai
40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
# 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。
3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1
@ -206,7 +222,7 @@ CRON_TZ=Asia/Shanghai
15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
# 财务台账:每天 02:30 从 AI 库 rpt 同步动账明细并重算流水。
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --quiet >/dev/null 2>&1
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1

View File

@ -7,6 +7,11 @@ REMOTE_DIR="${3:-/opt/caiwuxitong}"
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
RUN_MIGRATIONS="${5:-0}"
KEEP_IMAGES="${6:-2}"
EXPECTED_PREVIOUS_SHA="${7:?Checked production revision is required; update the deployment scripts}"
DEPLOY_ACTOR="${8:-unknown}"
RELEASE_APP_URL="${9:-}"
RELEASE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$RELEASE_DIR/release-guard.sh"
IMAGE_NAME="caiwu_akaunting"
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
@ -14,7 +19,9 @@ cleanup() {
rm -f "$IMAGE_ARCHIVE"
}
trap cleanup EXIT INT TERM
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if [ "$(id -u)" -ne 0 ]; then
echo "activate-release.sh must run as root" >&2
@ -69,6 +76,10 @@ if ! flock -n 9; then
exit 1
fi
RELEASE_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
RELEASE_RESULT=blocked
trap 'release_status=$?; caiwu_record_release_result "$release_status"; cleanup' EXIT
# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。
exec 8>/run/caiwu-upstream-sync.lock
echo "Waiting for active upstream synchronization before replacing the application container"
@ -78,7 +89,8 @@ if ! flock -w 1800 8; then
fi
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
caiwu_verify_release_base
RELEASE_RESULT=running
restore_previous_image() {
case "$PREVIOUS_IMAGE" in
@ -100,6 +112,8 @@ if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
exit 1
fi
caiwu_install_release_files
cd "$REMOTE_DIR"
export IMAGE_TAG RUN_MIGRATIONS
@ -200,6 +214,8 @@ CRON_TZ=Asia/Shanghai
40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
# 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。
3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1
@ -210,7 +226,7 @@ CRON_TZ=Asia/Shanghai
15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
# 财务台账:每天 02:30 从 AI 库 rpt 同步动账明细并重算流水。
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --quiet >/dev/null 2>&1
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1

View File

@ -0,0 +1,100 @@
-- 财务台账 AI 库轻量视图与索引
-- 目标库:JSReportReplica
-- 只新增视图/索引并授予 caiwu 只读 SELECT;不修改任何 base 业务数据。
-- 建议用具备 DDL 权限的维护账号执行。
CREATE OR ALTER VIEW rpt.fin_ledger_workorder AS
SELECT Id, woNo, createby, created, completeTime, businessFieldList
FROM rpt.jst_workOrderInfo
WHERE woTypeId = N'9E5A531F48D94AF4B8611FA6918ADA29'
AND status = N'CONFIRMED';
GO
CREATE OR ALTER VIEW rpt.fin_ledger_withdrawal AS
SELECT a.Id, a.shop_id, b.shop_name, a.channel, a.fee_time, a.fee_name, a.amount, a.primary_key
FROM rpt.jst_originalBillInfo a
LEFT JOIN rpt.jst_shopInfo b ON b.shop_id = a.shop_id
WHERE a.inout_type = N'-1'
AND a.fee_name LIKE N'%提现%'
AND a.fee_time >= '2026-01-01';
GO
CREATE OR ALTER VIEW rpt.fin_ledger_dingtalk_workflow AS
SELECT processInstanceId, processCode, businessId, title, status, originatorUserId,
originatorDeptName, joinAt, finishTime
FROM rpt.ding_Workflow
WHERE processCode IN (
'PROC-B290696F-EC2A-4F76-87BA-E0A12E10D09F',
'PROC-C5159B8D-F412-4DF8-981D-64EBB8D03EB6',
'PROC-39DD70DE-DAB2-4A5F-B8F1-0B0B68BD0C88',
'PROC-BBAC7187-9E59-4942-941A-59C7EAF0CEDA',
'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF',
'PROC-6407E98B-040A-4C96-AA7D-696650FC1A25'
);
GO
CREATE OR ALTER VIEW rpt.fin_ledger_dingtalk_scheme AS
SELECT s.id, s.processInstanceId, s.name, s.value
FROM rpt.ding_WorkflowScheme s
WHERE s.name IN (N'付款明细', N'收款费用明细', N'应付明细', N'固定资产报废或出售明细', N'收款合计', N'付款金额合计');
GO
CREATE OR ALTER VIEW rpt.fin_ledger_store_opening AS
SELECT a.flowId, a.AccountCode, a.AccountName, a.costType, a.money, a.paymentTime,
a.paymentAccount, b.erpShopName, b.platform, b.applyUser, b.applyDepartment
FROM rpt.fys_shopInfo_kingdeeCode_flow a
LEFT JOIN rpt.fys_shopInfo_flow b ON a.flowId = b.flowId
WHERE a.flowId IN (SELECT flowId FROM rpt.flow_flow WHERE flowType = 8 AND flowStatus = 3)
AND a.type = 1 AND a.isPaymentMade = 1 AND a.money > 0;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_base_jst_workOrderInfo_fin_ledger')
CREATE NONCLUSTERED INDEX IX_base_jst_workOrderInfo_fin_ledger
ON base.jst_workOrderInfo (Id)
INCLUDE (woNo, createby, created, completeTime)
WHERE woTypeId = N'9E5A531F48D94AF4B8611FA6918ADA29' AND status = N'CONFIRMED'
WITH (ONLINE = ON, MAXDOP = 2);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_base_jst_originalBillInfo_fin_ledger')
CREATE NONCLUSTERED INDEX IX_base_jst_originalBillInfo_fin_ledger
ON base.jst_originalBillInfo (fee_time)
INCLUDE (Id, shop_id, channel, fee_name, amount, primary_key)
WHERE inout_type = N'-1' AND fee_time >= '2026-01-01'
WITH (ONLINE = ON, MAXDOP = 4, SORT_IN_TEMPDB = ON);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_base_ding_Workflow_fin_ledger')
CREATE NONCLUSTERED INDEX IX_base_ding_Workflow_fin_ledger
ON base.ding_Workflow (joinAt, processCode)
INCLUDE (processInstanceId, businessId, title, status, originatorUserId, originatorDeptName, finishTime)
WITH (ONLINE = ON, MAXDOP = 2);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_base_flow_flow_fin_ledger')
CREATE NONCLUSTERED INDEX IX_base_flow_flow_fin_ledger
ON base.flow_flow (flowType, flowStatus, flowId)
INCLUDE (applyUser)
WITH (ONLINE = ON, MAXDOP = 2);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_base_fys_shopInfo_kingdeeCode_flow_fin_ledger')
CREATE NONCLUSTERED INDEX IX_base_fys_shopInfo_kingdeeCode_flow_fin_ledger
ON base.fys_shopInfo_kingdeeCode_flow (flowId)
INCLUDE (type, isPaymentMade, paymentTime, paymentAccount, costType, money, AccountCode, AccountName)
WITH (ONLINE = ON, MAXDOP = 2);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_base_fys_shopInfo_flow_fin_ledger')
CREATE NONCLUSTERED INDEX IX_base_fys_shopInfo_flow_fin_ledger
ON base.fys_shopInfo_flow (flowId)
INCLUDE (erpShopName, platform, applyUser, applyDepartment)
WITH (ONLINE = ON, MAXDOP = 2);
GO
GRANT SELECT ON OBJECT::rpt.fin_ledger_workorder TO [caiwu];
GRANT SELECT ON OBJECT::rpt.fin_ledger_withdrawal TO [caiwu];
GRANT SELECT ON OBJECT::rpt.fin_ledger_dingtalk_workflow TO [caiwu];
GRANT SELECT ON OBJECT::rpt.fin_ledger_dingtalk_scheme TO [caiwu];
GRANT SELECT ON OBJECT::rpt.fin_ledger_store_opening TO [caiwu];
GO

View File

@ -0,0 +1,31 @@
[
"accessory-reship:import-courier-configs",
"account-ledger-remarks:import",
"account-mapping:import-files",
"order-data:import-after-sales-cost",
"business:import-alibaba-account-balances",
"courier-freight:import",
"courier-quotes:import-billing-rules",
"dewu-bills:import-authoritative",
"business-mirror:import-douyin-authoritative",
"finance-ledger:import-base",
"finance-ledger:import-snapshot",
"internal-flow:import",
"inventory:inbound-detail:import",
"business-mirror:import-inventory-report-month",
"business-mirror:import-jd-daily",
"business-mirror:import-jd-wallet",
"jst-order-labels:import-planting-rules",
"jst-order-labels:import",
"business:import-jushuitan-shop-balances",
"pinduoduo-bills:import-authoritative",
"planting-details:import",
"product-catalog:import-authoritative-export",
"product:monthly-weighted-average:base",
"store-violations:import-rules",
"supplier-bookkeeping:import-configs",
"business:import-taobao-alipay-bills",
"taobao-alipay-bills:import-authoritative",
"taobao-wechat-bills:import-authoritative",
"business-mirror:import-third-party-warehouse-anchor"
]

View File

@ -0,0 +1,43 @@
#!/bin/sh
# Caller holds the production deployment lock. Does not restart any service.
set -eu
REMOTE_DIR="${1:-/opt/caiwuxitong}"
SOURCE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
LEGACY_STUB="$SOURCE_DIR/retired-activation.sh"
[ "$(id -u)" -eq 0 ] || { echo 'Release protection must be installed as root' >&2; exit 1; }
[ -f "$LEGACY_STUB" ] || { echo 'Missing retired activation stub' >&2; exit 1; }
is_immutable() { lsattr -d "$1" 2>/dev/null | awk '{print $1}' | grep -q i; }
needs_install=0
for legacy in activate-release.sh activate-incremental-release.sh; do
if cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"; then
continue
fi
if is_immutable "$REMOTE_DIR/$legacy"; then
echo "Unexpected immutable legacy entry point: $legacy; verify it before changing protection" >&2
exit 1
fi
needs_install=1
done
[ "$needs_install" -eq 1 ] || exit 0
# Test support on an owned temporary file before touching either entry point.
probe="$(mktemp "$REMOTE_DIR/.release-protection.XXXXXX")"
cleanup_probe() { chattr -i "$probe" 2>/dev/null || true; rm -f "$probe"; }
trap cleanup_probe EXIT
chattr +i "$probe" || { echo 'Filesystem cannot protect legacy entry points; no application was changed' >&2; exit 1; }
chattr -i "$probe"
backup="$REMOTE_DIR/runtime/private/release-protection/$(date -u +%Y%m%dT%H%M%SZ)-$$"
umask 077
mkdir -p "$backup"
for legacy in activate-release.sh activate-incremental-release.sh; do
if cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"; then
continue
fi
[ ! -f "$REMOTE_DIR/$legacy" ] || cp -p "$REMOTE_DIR/$legacy" "$backup/$legacy"
install -m 700 "$LEGACY_STUB" "$REMOTE_DIR/.$legacy.next"
mv "$REMOTE_DIR/.$legacy.next" "$REMOTE_DIR/$legacy"
chattr +i "$REMOTE_DIR/$legacy"
cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"
done
echo 'Legacy activation paths protected; current application and databases were not restarted'

72
deploy/release-guard.sh Normal file
View File

@ -0,0 +1,72 @@
#!/bin/sh
# Sourced by both activation paths after the deployment and synchronization locks.
caiwu_verify_release_base() {
case "$EXPECTED_PREVIOUS_SHA" in
none) ;;
*[!0-9a-f]*|'') echo 'Missing or invalid checked production revision; update the deployment scripts' >&2; return 1 ;;
*) [ "${#EXPECTED_PREVIOUS_SHA}" -eq 40 ] || return 1 ;;
esac
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
[ -n "$PREVIOUS_SHA" ] || PREVIOUS_SHA=none
if [ "$PREVIOUS_SHA" != none ] && ! printf '%s\n' "$PREVIOUS_SHA" | grep -Eq '^[0-9a-f]{40}$'; then
echo 'Invalid production revision marker; refusing activation' >&2
return 1
fi
if [ "$PREVIOUS_SHA" != "$EXPECTED_PREVIOUS_SHA" ]; then
echo "Production changed while this release was being prepared: checked=$EXPECTED_PREVIOUS_SHA current=$PREVIOUS_SHA requested=$DEPLOY_SHA. Fetch latest main and retry; the running application was not changed." >&2
return 1
fi
# A failed/interrupted older activation must not leave the marker ahead of the app.
running_sha="$(docker inspect caiwu_app --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null || true)"
if [ "$PREVIOUS_SHA" != none ] && [ "$running_sha" != "$PREVIOUS_SHA" ]; then
echo "Running image and production marker disagree; refusing activation: marker=$PREVIOUS_SHA image=$running_sha" >&2
return 1
fi
if [ "$PREVIOUS_SHA" = none ] && [ -n "$running_sha" ]; then
echo 'An application is already running without a production marker; verify its revision before deploying' >&2
return 1
fi
}
caiwu_install_release_files() {
# Each uploader has its own directory. Shared runtime files change only inside
# the activation lock and after the production revision has been rechecked.
[ "$RELEASE_DIR" != "$REMOTE_DIR" ] || return 0
release_files='compose.jiangling.yml activate-release.sh activate-incremental-release.sh release-guard.sh retired-activation.sh install-release-protection.sh config-db-init.sh run-finance-batch.sh run-finance-import.py finance-batch-imports.json clickhouse-resource-policy.xml update-runtime-url.sh configure-dingtalk-callback.sh host-nginx-8442-dingtalk.conf setup-incremental-registry.sh registry-config.yml'
for release_file in $release_files; do
[ -f "$RELEASE_DIR/$release_file" ] || { echo "Incomplete staged release: $release_file" >&2; return 1; }
done
sh "$RELEASE_DIR/install-release-protection.sh" "$REMOTE_DIR" || return 1
for release_file in $release_files; do
case "$release_file" in activate-release.sh|activate-incremental-release.sh) continue ;; esac
case "$release_file" in *.sh) release_mode=700 ;; *) release_mode=644 ;; esac
[ "$release_file" != config-db-init.sh ] || release_mode=644
install -m "$release_mode" "$RELEASE_DIR/$release_file" "$REMOTE_DIR/.$release_file.next"
mv "$REMOTE_DIR/.$release_file.next" "$REMOTE_DIR/$release_file"
done
if [ -n "$RELEASE_APP_URL" ]; then
"$REMOTE_DIR/update-runtime-url.sh" "$RELEASE_APP_URL" "$REMOTE_DIR"
fi
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.release-controls-sha.next"
mv "$REMOTE_DIR/.release-controls-sha.next" "$REMOTE_DIR/.release-controls-sha"
}
caiwu_record_release_result() {
release_exit="$1"
[ "$RELEASE_RESULT" != running ] || RELEASE_RESULT=failed
[ "$release_exit" -ne 0 ] || RELEASE_RESULT=success
# Client identity is declared by the workstation; the SSH source is recorded
# separately. Neither field is inferred from the commit author.
release_actor="$(printf '%s' "$DEPLOY_ACTOR" | tr -cd 'A-Za-z0-9_.@:-' | cut -c1-160)"
release_peer="$(printf '%s' "${SSH_CONNECTION:-local}" | tr -cd 'A-Za-z0-9 .:-' | cut -c1-160)"
(
umask 077
mkdir -p "$REMOTE_DIR/runtime/private"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$RELEASE_STARTED_AT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$release_actor" "$release_peer" \
"${PREVIOUS_SHA:-unknown}" "$DEPLOY_SHA" "$RELEASE_RESULT" "$release_exit" "$EXPECTED_PREVIOUS_SHA" \
>> "$REMOTE_DIR/runtime/private/deployments.tsv"
) || echo 'Could not write the private deployment receipt' >&2
}

View File

@ -0,0 +1,4 @@
#!/bin/sh
# This legacy shared path is intentionally retired and immutable on production.
echo 'Legacy activation is disabled. Fetch latest main and use the workstation deployment script with an isolated release directory and checked production revision. The running application was not changed.' >&2
exit 1

View File

@ -3,8 +3,12 @@ set -eu
REMOTE_DIR="${CAIWU_REMOTE_DIR:-/opt/caiwuxitong}"
LOCK_FILE="${CAIWU_UPSTREAM_LOCK:-/run/caiwu-upstream-sync.lock}"
is_import=0
CAIWU_BATCH_CALLER_DIR="$PWD"
export CAIWU_BATCH_CALLER_DIR
case "${1:-}" in
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run) ;;
*:import|*:import-*|*:import:*|product:monthly-weighted-average:base) is_import=1 ;;
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run|finance-ledger:recalculate) ;;
*) echo 'Unsupported finance batch command' >&2; exit 64 ;;
esac
cd "$REMOTE_DIR"
@ -34,6 +38,9 @@ if [ "$1" != 'account-bills:exports:run' ]; then
exit 75
fi
fi
if [ "$is_import" -eq 1 ]; then
exec python3 "$REMOTE_DIR/run-finance-import.py" "$@"
fi
image="$(docker inspect caiwu_app --format '{{.Config.Image}}')"
case "$image" in
caiwu_akaunting:*) IMAGE_TAG="${image#*:}" ;;

View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Run registered imports in the existing, resource-limited batch profile."""
import json
import os
import pathlib
import shutil
import signal
import subprocess
import sys
import tempfile
import uuid
def available_memory_kib():
meminfo = dict(line.split(':', 1) for line in pathlib.Path('/proc/meminfo').read_text().splitlines())
return int(meminfo['MemAvailable'].split()[0])
def main():
arguments = sys.argv[1:]
registry = json.loads(pathlib.Path(__file__).with_name('finance-batch-imports.json').read_text())
if not arguments or arguments[0] not in registry:
print('Unsupported import; register the command in finance-batch-imports.json first.', file=sys.stderr)
return 64
# Keep a GiB available to the host in addition to this import's one-GiB cap.
if available_memory_kib() < 2 * 1024 * 1024:
print('Available memory is below 2GiB; no import container started. Retry after other tasks finish.', file=sys.stderr)
return 75
environment = os.environ.copy()
environment['FINANCE_BATCH_MEMORY_LIMIT'] = '1g'
environment['RUN_MIGRATIONS'] = '0'
image = subprocess.check_output(['docker', 'inspect', 'caiwu_app', '--format', '{{.Config.Image}}'], text=True).strip()
if not image.startswith('caiwu_akaunting:'):
print('Unsupported active finance image; no import container started.', file=sys.stderr)
return 65
environment['IMAGE_TAG'] = image.split(':', 1)[1]
container_name = 'caiwu_import_' + uuid.uuid4().hex
command = ['docker', 'compose', '-f', 'compose.jiangling.yml', '--profile', 'batch', 'run', '--rm', '-T', '--no-deps', '--name', container_name]
# Copy explicit host input files into a private per-run directory. The source
# is never modified; the container gets read-only copies readable by www-data.
with tempfile.TemporaryDirectory(prefix='caiwu-import-') as staging:
inputs = pathlib.Path(staging) / 'inputs'
inputs.mkdir(mode=0o755)
staged_arguments = [arguments[0]]
staged_count = 0
for argument in arguments[1:]:
prefix = ''
value = argument
if argument.startswith(('--file=', '--path=')):
prefix, value = argument.split('=', 1)
prefix += '='
source = pathlib.Path(value)
if not source.is_absolute():
source = pathlib.Path(os.environ.get('CAIWU_BATCH_CALLER_DIR', os.getcwd())) / source
if (prefix or not argument.startswith('-')) and source.is_file():
parent = inputs / str(staged_count)
parent.mkdir(mode=0o755)
destination = parent / source.name
shutil.copyfile(source, destination)
destination.chmod(0o444)
staged_arguments.append(prefix + '/run/caiwu-import/' + parent.name + '/' + destination.name)
staged_count += 1
else:
staged_arguments.append(argument)
if staged_count:
command += ['--volume', str(inputs) + ':/run/caiwu-import:ro']
command += ['--entrypoint', 'php', 'batch', '-d', 'memory_limit=512M', 'artisan', *staged_arguments]
print('Import limits: 1 CPU, 1GiB RAM, no Swap, PHP 512MiB; existing synchronization lock held.', flush=True)
try:
return subprocess.run(command, env=environment, timeout=3600).returncode
except subprocess.TimeoutExpired:
print('Import exceeded one hour; stop and inspect its checkpoint before retrying.', file=sys.stderr)
return 124
except KeyboardInterrupt:
return 130
finally:
# Killing only the Compose client would leave the job consuming RAM.
subprocess.run(['docker', 'rm', '--force', container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
if __name__ == '__main__':
def interrupted(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, interrupted)
signal.signal(signal.SIGHUP, interrupted)
sys.exit(main())

View File

@ -35,6 +35,14 @@
抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。
### 成本价镜像观测
`business-mirror:sync-jushuitan-cost-price-history --source=snapshot` 仅比较既有 `ops_jst_sku` 的当前成本价,首次读取和新增商品只建立基线。基线写入 `jushuitan_cost_price_observation_baselines`,后续观测变化写入独立的 `jushuitan_cost_price_observations`;状态使用 `business_mirror_table_states` 中的 `jushuitan.product-cost-price-observations`,不共用正式操作历史的同步状态。生产保留的旧 SKU 级 `jushuitan_cost_price_snapshots` 不作覆盖、删除或追溯导入。
观测任务沿 `_mirror_synced_at + _mirror_key` 索引顺序分批续传,默认每批 500 条、每轮最多 20 批;固定窗口并保留两分钟重叠,基线、事件、游标同事务提交。不进行每轮全表计数。生产任务每十分钟尝试运行,沿用 `/run/caiwu-upstream-sync.lock`,繁忙时跳过。首次基线较大时可由同步节点持有同一互斥锁分批完成。
普通商品资料独立展示每个源商品最近 20 条镜像价格观测,时间明确表示“发现时间”。`modified` 只是商品更新时间,不能充当真实改价时间;采集间隔内的中间价格、建立基线前的历史无法从当前镜像还原。正式操作历史仍使用默认 `--source=open_api`;月度加权成本及其他财务计算继续只读原正式历史,不使用观测结果。
## 经营分析副本库(operations_replica)
店铺维度报表不使用订单件数代替销量。中央刷新节点按上游主键 `Id`
@ -52,7 +60,7 @@
| 订单 | `rpt.jst_Orders`、`rpt.jst_OrderItem` | `ops_jst_orders`、`ops_jst_order_item` |
| 商品与仓库 | `rpt.jst_sku`、`rpt.jst_warehouseInfo`、`rpt.jst_skumap` | `ops_jst_*`(普通商品资料已切换) |
| 推广费用 | `rpt.ads_itemid_sales_cost_ratio_v2_di` | `ops_item_sales_cost_ratio` |
| 退款退货 | `rpt.jst_returnAndRefundInfo`、`rpt.jst_returnAndRefundItem` | `ops_jst_return_refund_*` |
| 退款退货 | `rpt.jst_returnAndRefundInfo`、`rpt.jst_returnAndRefundItem`、`rpt.jst_qmcust_refundlist`、`rpt.jst_qmcust_refundlist_item` | `ops_jst_return_refund_*`、`ops_jst_qmcust_refunds`、`ops_jst_qmcust_refund_items` |
| 聚水潭店铺 | `rpt.jst_shopInfo` | `ops_jst_shop_info` |
| 聚水潭原始账单 | `rpt.jst_originalBillInfo` | `ops_jst_original_bill_info` |
| 店铺维度报表 | `jiansun/dbo.jst_originalBillInfo + dbo.jst_shopInfo + dbo.fys_shopInfo_flow` | `store_report_bill_rows`、`store_report_lifecycle_rows`、`store_report_daily_rows` |
@ -60,6 +68,10 @@
商品价格 `rpt.Bulk_Price_Limit` 继续使用已经上线的类型化同步器,不在通用原表任务中重复同步。
售后(退货退款)页面及其导出共同读取上述两套售后主单、各自商品明细与店铺镜像,按 `as_date` 筛选。原售后资料优先;退款清单按 `as_id`、或非空的 `(shop_id, outer_as_id)` 排除已存在的业务单。无商品明细的主单仍显示,商品数量、明细金额及成本留空;店铺平台无法唯一确认时标为“待确认”,不推断平台。两个来源的订单关联不得混用。订单号筛选按完整内部订单号、线上订单号或平台售后单号精确查询,避免长文本标识的全表扫描;店铺和商品仍支持包含搜索。分页先限制各来源的候选主单及行标识,再加载当前页明细;导出使用同一筛选及稳定游标。
`jst_returnAndRefundItem` 没有更新时间,除按 `Id` 追加外,每 168 小时通过现有调度分批执行可续传全量校准,覆盖历史编号变化及删除;全量完成后才清理上一代残留。人工历史校准前须备份相关镜像、目录及同步状态,并持有生产上游同步互斥锁。退款清单两表继续按 `local_updated` 使用重叠增量,不新建重复镜像。
## JianSun 旧业务库(jiansun)
| 业务范围 | 上游对象 |

View File

@ -129,11 +129,22 @@ bash scripts/deploy-from-mac.sh
8. 验证唯一私有七牛桶的写入、读取和删除,并把旧本机业务上传幂等复制到七牛;不删除本机原件;
9. 检查服务器本机后端页面;
10. 任一存储验证或页面验证失败时恢复上一个镜像;
11. 使用服务器互斥锁阻止两个人同时切换版本;
11. 每次上传使用独立暂存目录;服务器取得发布锁和同步锁后,再核对线上 SHA 与发布机已检查的 SHA,确认未变化才切换配置和应用;
12. 删除本地临时镜像包并关闭 SSH 隧道;
13. 开发机、服务器 Docker 和服务器 Registry 均仅保留当前和上一个本项目版本;
14. 清理 7 天前的本项目构建缓存,并将总量压到最多 3GB。
### 5.0 多人发布保护
- 远端 `main` 禁止直接推送和强推,任务分支通过 PR 合并;不额外要求人工审批或新增 CI 门槛。
- 发布机先刷新远端 `main`,正式发布必须与最新主线一致,并检查本次版本包含线上提交。
- 上传期间若别人已发布,服务器在锁内拒绝本次激活,运行中的应用保持原版本。先 `git fetch`、快进到最新 `main` 并重新发布,不用回滚开关绕过并发校验。
- Windows 和 macOS 发布机都必须使用最新脚本。共享根目录的两个旧激活入口已停用并设置 Linux immutable 属性,旧发布机无法用上传覆盖它们;新脚本从每次独立暂存目录执行激活,缺少已检查线上版本参数也会被拒绝。
- 运行配置按每次发布独立暂存,核对通过后才在锁内安装;上传文件不会提前覆盖其他发布使用的配置。
- 服务器私有 `runtime/private/deployments.tsv` 记录起止 UTC 时间、发布机声明的身份、SSH 来源、前后 SHA、结果、退出码和已检查的 SHA。声明身份及提交作者不能单独证明具体操作者;记录不包含凭据或业务数据。
- 安装旧入口保护前自动备份到服务器私有 `runtime/private/release-protection/`。普通发布不解除 immutable 属性;维护这些停用入口时先核对备份和目的,再由维护人员使用 `chattr -i` 处理指定文件。应用镜像回滚不需要启用旧入口。
- 仅维护发布控制脚本时,可在发布锁内备份并安装已合并主线的控制脚本,记录独立的 `.release-controls-sha`,不重建应用容器、不修改表示应用镜像版本的 `.deployed-sha`。
### 5.1 安全软件拦截 regctl 时的降级
第 6 步默认用项目专用 `regctl`(未签名)把 BuildKit 原生 OCI 分层包推入服务器本机 Registry。少数开发机的 360 安全卫士、金山毒霸等安全软件会拦截未签名程序,表现为 `regctl-v0.11.5.exe` 启动即报“拒绝访问”。
@ -278,6 +289,14 @@ sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:order-match:sync YYYY-MM
入口持有现有上游同步锁,使用 `batch` Profile 的独立容器(1 核、3GiB 内存、不使用 Swap、无 Web 健康检查);正常发布不启动该 Profile。先备份再执行回补,失败时保留原有未完成状态,不重复并行启动任务。店铺候选平时按镜像同步时间增量刷新,02:45 使用 `--full` 全量核对。ClickHouse 系统日志的保留期由 `deploy/clickhouse-resource-policy.xml` 管理;业务表不受日志策略影响。
主应用独立限制为 2GiB 内存,不额外使用 Swap。生产 CLI 导入由同一入口调度,但采用更小的独立额度:1 核、1GiB 内存、PHP 512MiB、不使用 Swap,最长一小时;宿主机可用内存不足 2GiB 时不启动。导入与上游写同步共用互斥锁,退出、超时或中断后清理本次容器;不自动重试或重复导入。既有其他批任务仍使用原来的 3GiB 额度。
```bash
sh /opt/caiwuxitong/run-finance-batch.sh order-data:import-after-sales-cost /绝对路径/售后费用.xlsx --dry-run
```
输入文件可使用宿主机路径或容器共享 `storage` 路径。宿主机文件会生成临时只读副本,保留原文件名,原件不修改;支持 `--file=/绝对路径/文件.xlsx`。生产网页容器直接运行导入命令会在解析文件前拒绝。本地开发、测试环境沿用原用法。新增导入须登记 `deploy/finance-batch-imports.json`,验证代表性文件的耗时和峰值内存后再发布。正式财务写入仍遵循原有授权及备份要求,使用批入口不等于授权数据修复。
查件运单索引 `work_order_waybill_lookup` 只保存原始工单镜像的稳定键、运单号与排序键;工单 JSON 仍为事实来源。升级后先通过上述批入口执行 `business-mirror:index-work-order-waybills --max-batches=0 --pause-ms=50` 完成一次可续传初始化,完成前保留现有核验路径。其后索引与原始工单批次在同一事务中更新,全量校准同时移除失效关联。不得每次导入重新扫描工单 JSON。
销量汇总按 `store_report_paid_sales_dirty_dates` 中的受影响日期更新。业绩明细写入与汇总共用互斥锁,改金额、替换账期和删除都必须登记旧日期及新日期。镜像人工校准后,可通过批入口执行 `business-mirror:refresh-paid-sales --full` 全量重建派生汇总;普通刷新无需全表指纹扫描。科目重算每轮默认处理一个平台月份,再释放同步锁,剩余任务由下一轮继续。

View File

@ -16,8 +16,12 @@
| `fin_daily_flows` | 每日资金流水 |
| `fin_account_map` | 来源值 → 账户简称 |
| `fin_account_map_events` | 映射修改审计 |
| `fin_account_statuses` | 账户状态维护(正常、网银异常、限额、工商注销) |
| `fin_account_type_overrides` | 账户类型人工维护值 |
迁移:`database/migrations/2026_10_19_160000_create_finance_ledger_tables.php`。
迁移:`database/migrations/2026_10_19_160000_create_finance_ledger_tables.php`、
`database/migrations/2026_10_20_120000_create_finance_ledger_account_statuses.php`、
`database/migrations/2026_10_20_140000_create_finance_ledger_account_type_overrides.php`。
## 本地导入
@ -34,7 +38,7 @@ docker compose -f docker-compose.local.yml exec -u www-data app \
```bash
docker compose -f docker-compose.local.yml exec -u www-data app \
php artisan finance-ledger:sync-rpt
php artisan finance-ledger:sync-rpt --days=7 --merge
```
本地尚未接入共享系统数据库时,可在 `runtime/.env` 临时启用
@ -57,14 +61,49 @@ docker compose -f docker-compose.local.yml exec -u www-data app \
## 页面
- 路由:`/workspace/financial-ledger/ledger`
- 路由:`/workspace/financial-operations/ledger`(旧地址会自动跳转)
- 页面:账户总表、店铺台账、每日资金流水、动账明细、账户映射、数据来源
- 写权限:账户映射修改仅系统管理员;其他账号只读
- 调度:`finance-ledger:sync-rpt` 每天 02:30(Asia/Shanghai)
- 账户总表支持导出空白 Excel 录入模板,包含页面列、状态和账户类型选项及填写说明
- 写权限:账户映射、账户状态和账户类型修改仅系统管理员及配置的财务台账管理员角色;其他账号只读
- 调度:`finance-ledger:sync-rpt --days=7 --merge` 每天 02:30(Asia/Shanghai)
账户状态独立保存在 `fin_account_statuses`,账户类型人工维护值独立保存在
`fin_account_type_overrides`,重新导入账户基础数据均不会覆盖;页面修改使用
异步保存,不触发整页刷新。
“账户映射”页支持按店铺台账批量关联:仅匹配 `source = shop`、来源值与
`fin_shops.shop_name` 完全一致、店铺回款账户简称存在于 `fin_accounts`
的未映射记录。批量操作写入映射审计并重算流水,未精确匹配的来源值保留人工处理。
同时支持按账户总表批量关联供应商应付:公司名称与 `fin_accounts.company_name`
完全一致时,只有唯一账户或唯一公户才自动映射;存在多公户或仅私人卡等歧义的公司
保留人工选择。
生产宿主机 `/etc/cron.d` 仍需增加每日调用入口;本项目当前没有运行
`schedule:run` 的容器,调度登记不能替代宿主机 cron。
## AI 库前置对象
生产同步读取以下轻量视图,不再直接查大表:
- `rpt.fin_ledger_workorder`
- `rpt.fin_ledger_withdrawal`
- `rpt.fin_ledger_dingtalk_workflow`
- `rpt.fin_ledger_dingtalk_scheme`
- `rpt.fin_ledger_store_opening`
对应索引、视图和 `caiwu` 只读授权脚本在 `deploy/ai-fin-ledger-views.sql`。
AI 库 ETL 重建 base 表后,需要确认这些索引仍存在。
日常同步使用最近 7 天增量合并:
```bash
php artisan finance-ledger:sync-rpt --days=7 --merge
```
历史全量(会删除并重建 `fin_transactions`)使用 `--from/--to`,仅在明确需要重建快照时执行。
## 数据安全
真实期初、账户余额和动账明细不得提交到 Git。本地导入文件放在仓库外的
@ -78,14 +117,14 @@ docker compose -f docker-compose.local.yml exec -u www-data app \
```bash
docker exec -u www-data caiwu_app php artisan finance-ledger:import-base /path/银行当前余额-*.xlsx --recalculate
docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt
docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge
docker exec -u www-data caiwu_app php artisan finance-ledger:verify
```
4. 在宿主机 `/etc/cron.d/caiwu-jiansun-snapshots` 增加:
```cron
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --quiet >/dev/null 2>&1
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1
```
5. 在「系统设置 → 权限」把「财务台账」页面分配给财务角色;账户映射修改允许超级管理员和「财务台账管理员」角色(可用 `FINANCE_LEDGER_MAPPING_EDITOR_ROLES` 覆盖)。

View File

@ -1,5 +1,18 @@
# 钉钉授权与财务花名册自动开户
## 供应商门户身份
供应商人员与财务人员使用不同的准入来源:
- 财务人员继续按 `hr_flowerName_book`、任职登记和财务部门范围核验。
- 供应商人员按当前有效的 `hr_employee` 企业成员、`hr_employee.dept_id_list` 与 `hr_groups` 祖先组织核验,不要求进入财务花名册。
- 部门祖先名称必须与共享供应商主档中的供应商名称精确匹配且唯一;同名多供应商、同一员工命中多个供应商或组织循环时拒绝登录。
- 首次登录自动创建 `dingtalk_supplier_portal` 普通账号,并写入 `supplier_portal_identities`。不创建财务角色、不创建供应商联系人、不授予财务页面权限。
- 供应商账号只允许“供应商对账 → 厂家端数据”及其模板、上传、确认、异议和导出接口。服务端始终覆盖请求中的供应商参数,并校验退货明细属于绑定供应商。
- 厂家页面显示只读供应商名称,不返回其他供应商候选列表。
- 会话每 5 分钟复核钉钉在职状态和供应商组织;人员停用、离开供应商组织或组织归属不唯一时立即失效。上游故障时返回 503,不使用过期授权继续展示数据。
- 供应商别名尚未启用;“常州市可乐办公家具有限公司”与“可乐办公”的对应关系未核实前按未授权处理。
## 用户流程
首次钉钉授权成功后,系统用企业 `userid` 对应 HR 花名册中的 `flowerNameId`,核验人员状态和组织归属,自动创建普通用户、绑定钉钉身份并登录。不要求填写注册表、设置密码或等待账号绑定审批。

View File

@ -0,0 +1,183 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 订单售后费用明细(聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」导入)。
*
* 聚水潭报表已经按商品编码(组合装拆成组件)给出行和成本,页面直接读这张导入表,
* 不再从订单明细里自己拆组合装。
*/
class AfterSalesCostRowRepository
{
private const TABLE = 'ops_after_sales_cost_rows';
private ?bool $available = null;
public function available(): bool
{
if ($this->available === null) {
try {
$this->available = Schema::hasTable(self::TABLE)
&& DB::table(self::TABLE)->exists();
} catch (\Throwable) {
$this->available = false;
}
}
return $this->available;
}
/**
* 导入数据是否覆盖本次筛选(有至少一行命中即视为覆盖)。
*
* @param array<string,mixed> $filters
*/
public function covers(array $filters): bool
{
if (! $this->available()) {
return false;
}
// 指定单号时,导入表里命中该单就完整(同单的所有明细行都在导入数据里)。
if (trim((string) ($filters['order_no'] ?? '')) !== '') {
return $this->filtered($filters)->limit(1)->exists();
}
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
// 不限日期时无法保证覆盖范围,回退镜像,避免把导入数据当成全量。
if ($from === '' || $to === '') {
return false;
}
$range = $this->filtered(['shop_names' => $filters['shop_names'] ?? []])
->selectRaw('MIN(order_time) as min_time, MAX(order_time) as max_time')
->first();
if (! $range || $range->min_time === null) {
return false;
}
// 只有导入范围完整覆盖所选日期时才使用导入数据,避免跨月时数据不全。
return substr((string) $range->min_time, 0, 10) <= $from
&& substr((string) $range->max_time, 0, 10) >= $to;
}
/**
* @param array<string,mixed> $filters
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
public function paginate(array $filters, int $page, int $limit): array
{
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$records = $this->filtered($filters)
->orderByDesc('order_time')
->orderByDesc('id')
->forPage($page, $limit + 1)
->get();
$rows = [];
foreach ($records as $record) {
$payload = json_decode((string) $record->payload, true);
$rows[] = is_array($payload) ? $payload : [];
}
return [
'rows' => array_slice($rows, 0, $limit),
'has_more' => count($rows) > $limit,
];
}
/**
* 导入一行(命令内部使用,批量写入)。
*
* @param array<string,mixed> $payload
*/
public static function toRecord(array $payload, ?string $importedAt = null): array
{
// 以整行内容做键:同一单同一商品可能出现多行,只有完全相同的一行才合并。
ksort($payload);
$rowKey = sha1((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$now = $importedAt ?? now()->toDateTimeString();
return [
'row_key' => $rowKey,
'shop_name' => mb_substr((string) ($payload['shop_name'] ?? ''), 0, 191),
'shop_code' => mb_substr((string) ($payload['shop_code'] ?? ''), 0, 64),
'online_order_no' => mb_substr((string) ($payload['online_order_no'] ?? ''), 0, 64),
'internal_order_no' => mb_substr((string) ($payload['internal_order_no'] ?? ''), 0, 64),
'product_code' => mb_substr((string) ($payload['product_code'] ?? ''), 0, 191),
'order_time' => self::dateTime($payload['order_time'] ?? null),
'product_quantity' => (float) ($payload['product_quantity'] ?? 0),
'product_amount' => (float) ($payload['product_amount'] ?? 0),
'product_cost' => (float) ($payload['product_cost'] ?? 0),
'cost_source' => mb_substr((string) ($payload['cost_source'] ?? ''), 0, 32),
'combine_type' => mb_substr((string) ($payload['combine_type'] ?? ''), 0, 32),
'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'imported_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}
/** @param array<string,mixed> $filters */
private function filtered(array $filters): Builder
{
$query = DB::table(self::TABLE);
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
$orderNo = trim((string) ($filters['order_no'] ?? ''));
$sku = trim((string) ($filters['sku_id'] ?? ''));
$shops = array_values(array_filter((array) ($filters['shop_names'] ?? []), static fn ($v) => trim((string) $v) !== ''));
if ($from !== '') {
$query->where('order_time', '>=', $from . ' 00:00:00');
}
if ($to !== '') {
$query->where('order_time', '<=', $to . ' 23:59:59');
}
if ($shops !== []) {
$query->whereIn('shop_name', $shops);
}
if ($orderNo !== '') {
$query->where(function (Builder $inner) use ($orderNo): void {
$inner->where('online_order_no', 'like', $orderNo . '%')
->orWhere('internal_order_no', 'like', $orderNo . '%')
->orWhere('internal_order_no', '=', $orderNo);
});
}
if ($sku !== '') {
$query->where('product_code', 'like', '%' . addcslashes($sku, '%_\\') . '%');
}
return $query;
}
private static function dateTime(mixed $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
}

View File

@ -0,0 +1,206 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 订单售后费用明细:聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」原表。
*
* 原表 rpt.jst_goods_profit_kpi_items_afs 的列名是 undefined1..undefinedN,
* 中文表头放在 rpt.jst_goods_profit_kpi_items_afs_schemas(column → name)。
* 两张表都镜像到共享业务库后,页面按 schemas 映射读取字段。
*/
class GoodsProfitAfsRepository
{
public const MAIN_DATASET = 'operations_replica.rpt.jst_goods_profit_kpi_items_afs';
public const SCHEMA_DATASET = 'operations_replica.rpt.jst_goods_profit_kpi_items_afs_schemas';
private const MAIN_TABLE = 'ops_jst_goods_profit_kpi_items_afs';
private const SCHEMA_TABLE = 'ops_jst_goods_profit_kpi_items_afs_schemas';
/** @var array<string,string>|null */
private ?array $fieldColumns = null;
public function ready(): bool
{
try {
return Schema::connection($this->connectionName())->hasTable(self::MAIN_TABLE)
&& Schema::connection($this->connectionName())->hasTable(self::SCHEMA_TABLE)
&& $this->initialSyncCompleted()
&& $this->connection()->table(self::MAIN_TABLE)->exists();
} catch (\Throwable) {
return false;
}
}
/** 首次全量同步完成前不对外使用,避免页面读到不完整的数据。 */
private function initialSyncCompleted(): bool
{
$state = $this->connection()->table('business_mirror_table_states')
->where('dataset_key', self::MAIN_DATASET)
->first(['initial_completed_at']);
return $state !== null && $state->initial_completed_at !== null;
}
/**
* 页面字段键 => 上游列名(undefinedN)。
*
* @return array<string,string>
*/
public function fieldColumns(): array
{
if ($this->fieldColumns !== null) {
return $this->fieldColumns;
}
$headers = (array) config('order_after_sales_cost.headers', []);
$map = [];
$existing = array_map(
'strtolower',
Schema::connection($this->connectionName())->getColumnListing(self::MAIN_TABLE),
);
foreach ($this->connection()->table(self::SCHEMA_TABLE)->orderBy('id')->get(['column', 'name']) as $schema) {
$label = self::normalizeLabel((string) $schema->name);
$field = $headers[$label] ?? null;
$column = (string) $schema->column;
// 同一科目出现多次(如 800105佣金 / 800105平台佣金)时保留第一次映射。
// schemas 里有 170 条,但原表只有 160 列,提前过滤掉不存在的列。
if ($field !== null && ! isset($map[$field]) && in_array(strtolower($column), $existing, true)) {
$map[$field] = $column;
}
}
return $this->fieldColumns = $map;
}
/** @param array<string,mixed> $filters */
public function covers(array $filters): bool
{
return $this->ready() && $this->filtered($filters, $this->fieldColumns())->limit(1)->exists();
}
/**
* @param array<string,mixed> $filters
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
public function paginate(array $filters, int $page, int $limit): array
{
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$columns = $this->fieldColumns();
$select = array_values(array_unique(array_merge(['id'], array_values($columns))));
$orderColumn = $columns['order_time'] ?? null;
$query = $this->filtered($filters, $columns)->select($select);
if ($orderColumn !== null) {
$query->orderByRaw("STR_TO_DATE(REPLACE(`{$orderColumn}`, '/', '-'), '%Y-%m-%d %H:%i:%s') desc");
}
$records = $query->orderByDesc('id')->forPage($page, $limit + 1)->get();
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
$rows = [];
foreach ($records as $record) {
$row = [];
foreach ($columns as $field => $column) {
$value = $record->{$column} ?? null;
if (isset($numberKeys[$field])) {
$value = $value === null ? null : (float) $value;
}
$row[$field] = is_string($value) ? trim($value) : $value;
}
$rows[] = $row;
}
return [
'rows' => array_slice($rows, 0, $limit),
'has_more' => count($rows) > $limit,
];
}
/**
* @param array<string,mixed> $filters
* @param array<string,string> $columns
*/
private function filtered(array $filters, array $columns): \Illuminate\Database\Query\Builder
{
$query = $this->connection()->table(self::MAIN_TABLE);
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
$orderNo = trim((string) ($filters['order_no'] ?? ''));
$sku = trim((string) ($filters['sku_id'] ?? ''));
$shops = array_values(array_filter((array) ($filters['shop_names'] ?? []), static fn ($v) => trim((string) $v) !== ''));
// 原表的订单时间是字符串(如 2026/8/23 14:58:51,不补零),不能直接和 ISO 日期做字符串比较。
if (($from !== '' || $to !== '') && isset($columns['order_time'])) {
$expression = "STR_TO_DATE(REPLACE(`{$columns['order_time']}`, '/', '-'), '%Y-%m-%d %H:%i:%s')";
if ($from !== '') {
$query->whereRaw($expression . ' >= ?', [$from . ' 00:00:00']);
}
if ($to !== '') {
$query->whereRaw($expression . ' <= ?', [$to . ' 23:59:59']);
}
}
if ($shops !== [] && isset($columns['shop_name'])) {
$query->whereIn($columns['shop_name'], $shops);
}
if ($orderNo !== '') {
$query->where(function ($inner) use ($columns, $orderNo): void {
$matched = false;
foreach (['online_order_no', 'internal_order_no'] as $field) {
if (! isset($columns[$field])) {
continue;
}
$inner->orWhere($columns[$field], 'like', $orderNo . '%');
$matched = true;
}
if (! $matched) {
$inner->whereRaw('1 = 0');
}
});
}
if ($sku !== '' && isset($columns['product_code'])) {
$query->where($columns['product_code'], 'like', '%' . addcslashes($sku, '%_\\') . '%');
}
return $query;
}
private function connection(): \Illuminate\Database\ConnectionInterface
{
return DB::connection($this->connectionName());
}
private function connectionName(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
private static function normalizeLabel(string $label): string
{
$label = str_replace(["\u{3000}", "\u{00A0}"], ' ', $label);
$label = preg_replace('/\s+/u', ' ', $label) ?? $label;
return trim($label);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
class JushuitanCostPriceObservationRepository
{
public function __construct(private readonly GenericBusinessMirrorRepository $mirror) {}
/** 仅当前页每个源商品最近 20 条观测;不混入正式财务取价历史。 */
public function attach(array $rows): void
{
foreach ($rows as $row) {
$row->jushuitan_cost_price_observations = [];
}
$db = $this->mirror->connection();
if ($rows === [] || ! Schema::connection($db->getName())->hasTable(JushuitanCostPriceSnapshotDiffer::OBSERVATIONS)) {
return;
}
$keys = collect($rows)->pluck('_mirror_key')->filter()->unique()->values()->all();
if ($keys === []) {
return;
}
$ranked = $db->table(JushuitanCostPriceSnapshotDiffer::OBSERVATIONS)
->whereIn('source_key', $keys)
->select('*')->selectRaw('ROW_NUMBER() OVER (PARTITION BY source_key ORDER BY observed_at DESC, id DESC) AS observation_rank');
$events = $db->query()->fromSub($ranked, 'ranked')->where('observation_rank', '<=', 20)
->orderBy('observed_at', 'desc')->get()->groupBy('source_key');
foreach ($rows as $row) {
$row->jushuitan_cost_price_observations = ($events->get($row->_mirror_key ?? '') ?? collect())->map(static fn ($event) => [
'id' => $event->id, 'source' => '镜像价格观测(非完整改价历史)',
'record_type_label' => '观测到成本价变化', 'effect_start_time' => $event->observed_at,
'before_price' => $event->before_price, 'after_price' => $event->after_price,
'diff_amount' => (float) $event->after_price - (float) $event->before_price,
'operator' => '-', 'operation' => '镜像快照比较',
'remark' => '上次观测:' . $event->previous_observed_at . ';本次为发现时间,不是真实改价时间。',
])->all();
}
}
}

View File

@ -17,6 +17,12 @@ class NonJingxiRefundMirrorRepository
public const ITEM_DATASET = 'operations_replica.rpt.jst_returnAndRefundItem';
public const SUPPLEMENT_DATASET = 'operations_replica.rpt.jst_qmcust_refundlist';
public const SUPPLEMENT_ITEM_DATASET = 'operations_replica.rpt.jst_qmcust_refundlist_item';
public const SHOP_DATASET = 'operations_replica.rpt.jst_shopInfo';
private const AUXILIARY_DATASETS = [
'operations_replica.rpt.jst_Orders',
'operations_replica.rpt.jst_OrderItem',
@ -25,14 +31,21 @@ class NonJingxiRefundMirrorRepository
];
private const REQUIRED_QUERY_INDEXES = [
self::INFO_DATASET => [['as_date', 'Id'], ['o_id', 'Id'], ['as_id', 'Id']],
self::INFO_DATASET => [['Id'], ['as_date', 'Id'], ['o_id', 'Id'], ['as_id', 'Id'], ['shop_id', 'outer_as_id'], ['so_id'], ['outer_as_id'], ['as_date', 'Id', 'as_id', 'shop_site']],
self::ITEM_DATASET => [['as_id', 'Id']],
self::SUPPLEMENT_DATASET => [['as_id'], ['as_date', 'as_id'], ['so_id'], ['outer_as_id']],
self::SUPPLEMENT_ITEM_DATASET => [['as_id', 'asi_id']],
self::SHOP_DATASET => [['shop_id', 'Id']],
'operations_replica.rpt.jst_Orders' => [['o_id', 'Id']],
'operations_replica.rpt.jst_OrderItem' => [['o_id', 'Id']],
];
private readonly ConnectionInterface $connection;
private ?array $excludedShopIds = null;
private ?string $supplementDateIndex = null;
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
@ -45,14 +58,14 @@ class NonJingxiRefundMirrorRepository
$schema = Schema::connection($this->connection->getName());
return $schema->hasTable('business_mirror_table_states')
&& $schema->hasTable($this->dataset(self::INFO_DATASET)->targetTable())
&& $schema->hasTable($this->dataset(self::ITEM_DATASET)->targetTable());
&& collect([self::INFO_DATASET, self::ITEM_DATASET, self::SUPPLEMENT_DATASET, self::SUPPLEMENT_ITEM_DATASET, self::SHOP_DATASET])
->every(fn (string $key): bool => $schema->hasTable($this->dataset($key)->targetTable()));
}
public function ready(): bool
{
return $this->mirror->ready($this->dataset(self::INFO_DATASET))
&& $this->mirror->ready($this->dataset(self::ITEM_DATASET));
return collect([self::INFO_DATASET, self::ITEM_DATASET, self::SUPPLEMENT_DATASET, self::SUPPLEMENT_ITEM_DATASET, self::SHOP_DATASET])
->every(fn (string $key): bool => $this->mirror->ready($this->dataset($key)));
}
public function queryReady(): bool
@ -66,7 +79,13 @@ class NonJingxiRefundMirrorRepository
return false;
}
$indexes = collect($schema->getIndexes($table))
$tableIndexes = collect($schema->getIndexes($table));
if ($datasetKey === self::SUPPLEMENT_DATASET) {
$this->supplementDateIndex = $tableIndexes->first(
static fn (array $index): bool => array_map('strtolower', $index['columns']) === ['as_date', 'as_id']
)['name'] ?? null;
}
$indexes = $tableIndexes
->map(static fn (array $index): array => array_map(
'strtolower',
(array) ($index['columns'] ?? []),
@ -90,6 +109,9 @@ class NonJingxiRefundMirrorRepository
return [
self::INFO_DATASET => $this->mirror->state($this->dataset(self::INFO_DATASET)),
self::ITEM_DATASET => $this->mirror->state($this->dataset(self::ITEM_DATASET)),
self::SUPPLEMENT_DATASET => $this->mirror->state($this->dataset(self::SUPPLEMENT_DATASET)),
self::SUPPLEMENT_ITEM_DATASET => $this->mirror->state($this->dataset(self::SUPPLEMENT_ITEM_DATASET)),
self::SHOP_DATASET => $this->mirror->state($this->dataset(self::SHOP_DATASET)),
];
}
@ -100,7 +122,7 @@ class NonJingxiRefundMirrorRepository
public function syncStatus(?array $states = null, ?CarbonInterface $at = null): array
{
$states ??= $this->states();
if (count(array_filter($states, 'is_array')) !== 2) {
if (count(array_filter($states, 'is_array')) !== 5) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
@ -143,19 +165,10 @@ class NonJingxiRefundMirrorRepository
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->baseQuery();
$this->applyFilters($query, $filters);
$total = (clone $query)->count();
$rows = $query
->select($this->selectColumns())
->orderByDesc('r.as_date')
->orderByDesc('r.Id')
->orderByDesc('i.Id')
->forPage($page, $perPage)
->get();
$this->hydratePage($rows);
$total = $this->sourceQuery($filters, false)->count()
+ $this->sourceQuery($filters, true)->count();
$keys = $this->orderedKeys($filters, null, $page * $perPage)->forPage($page, $perPage)->get();
$rows = $this->loadRows($keys);
return ['data' => $rows->all(), 'total' => $total];
}
@ -174,38 +187,24 @@ class NonJingxiRefundMirrorRepository
}
$chunkSize = min(max($chunkSize, 1), 100);
$query = $this->baseQuery();
$this->applyFilters($query, $filters);
$page = 1;
$cursor = null;
do {
$rows = (clone $query)
->select($this->selectColumns())
->orderByDesc('r.as_date')
->orderByDesc('r.Id')
->orderByDesc('i.Id')
->forPage($page, $chunkSize)
->get();
if ($rows->isEmpty()) {
$keys = $this->orderedKeys($filters, $cursor, $chunkSize)->limit($chunkSize)->get();
if ($keys->isEmpty()) {
break;
}
$this->hydratePage($rows);
foreach ($rows as $row) {
foreach ($this->loadRows($keys) as $row) {
yield $row;
}
$page++;
} while ($rows->count() === $chunkSize);
$cursor = $keys->last();
} while ($keys->count() === $chunkSize);
}
/** @return list<string> */
private function selectColumns(): array
{
return [
'i.Id as Id', 'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id',
'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id',
'r.o_id', 'r.so_id', 'r.type', 'r.node', 'r.shop_type', 'r.shop_name',
'r.shop_site as platform', 'r.refund as totalRefundMoney',
'i.outer_oi_id', 'i.shop_sku_id', 'i.i_id', 'i.sku_id', 'i.qty',
@ -216,17 +215,223 @@ class NonJingxiRefundMirrorRepository
];
}
private function baseQuery(): Builder
private function sourceQuery(array $filters, bool $supplement, bool $withItems = true): Builder
{
return $this->connection->table('ops_jst_return_refund_info as r')
->join('ops_jst_return_refund_item as i', 'i.as_id', '=', 'r.as_id')
->where(function (Builder $query): void {
$query->whereNull('r.shop_site')
->orWhere(function (Builder $platform): void {
$platform->where('r.shop_site', 'not like', '%京喜%')
->whereRaw('LOWER(r.shop_site) NOT LIKE ?', ['%jingxi%']);
});
$table = $supplement ? 'ops_jst_qmcust_refunds' : 'ops_jst_return_refund_info';
$query = $this->connection->table($table . ' as r');
if ($supplement && $filters['order_no'] === '' && $this->supplementDateIndex !== null
&& $this->connection->getDriverName() === 'mysql') {
// The legacy text payload makes a full table scan very expensive;
// keep date windows on the verified shared-mirror lookup index.
$query->forceIndex($this->supplementDateIndex);
}
if ($filters['order_no'] !== '') {
$needle = trim($filters['order_no']);
$key = $supplement ? 'as_id' : 'Id';
$matches = $this->connection->table($table)->select($key . ' as matched_id')->where('so_id', $needle)
->union($this->connection->table($table)->select($key . ' as matched_id')->where('outer_as_id', $needle));
if (ctype_digit($needle)) {
$matches->union($this->connection->table($table)->select($key . ' as matched_id')->where('o_id', $needle));
}
// Separate indexed lookups: MariaDB cannot reliably index-merge
// OR predicates across the replica's long text identifier fields.
$query->joinSub($matches, 'matching_refund', 'matching_refund.matched_id', '=', 'r.' . $key);
}
$itemTable = $supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item';
if ($withItems) {
$query->leftJoin($itemTable . ' as i', 'i.as_id', '=', 'r.as_id');
}
$platform = 'r.shop_site';
if ($supplement) {
// A shop may have several historical rows. An ambiguous platform is
// left unknown, and must never multiply the refund rows.
$this->excludedShopIds ??= $this->connection->table('ops_jst_shop_info')
->get(['shop_id', 'shop_site'])->groupBy('shop_id')
->filter(function ($rows): bool {
$sites = $rows->pluck('shop_site')->filter()->unique();
$site = (string) $sites->first();
return $sites->count() === 1 && (str_contains($site, '京喜') || str_contains(strtolower($site), 'jingxi'));
})->keys()->map('strval')->all();
if ($this->excludedShopIds !== []) {
$query->where(fn (Builder $q) => $q->whereNull('r.shop_id')->orWhereNotIn('r.shop_id', $this->excludedShopIds));
}
$query->whereNotExists(function (Builder $existing): void {
$existing->selectRaw('1')->from('ops_jst_return_refund_info as existing')
->whereColumn('existing.as_id', 'r.as_id');
})
->whereNotExists(function (Builder $existing): void {
$existing->selectRaw('1')->from('ops_jst_return_refund_info as existing')
->whereNotNull('r.outer_as_id')->where('r.outer_as_id', '!=', '')
->whereNotNull('r.shop_id')->where('r.shop_id', '!=', '')
->whereColumn('existing.shop_id', 'r.shop_id')
->whereColumn('existing.outer_as_id', 'r.outer_as_id');
});
$platform = null;
}
if ($platform !== null) {
$query->where(function (Builder $query) use ($platform): void {
$query->whereNull($platform)->orWhere(function (Builder $query) use ($platform): void {
$query->where($platform, 'not like', '%京喜%')
->whereRaw('LOWER(' . $platform . ') NOT LIKE ?', ['%jingxi%']);
});
});
}
$this->applyFilters($query, $filters);
if ($filters['sku_id'] !== '') {
if ($withItems) {
$this->applySkuFilter($query, $filters['sku_id']);
} else {
$query->whereExists(function (Builder $items) use ($itemTable, $filters): void {
$items->selectRaw('1')->from($itemTable . ' as i')->whereColumn('i.as_id', 'r.as_id');
$this->applySkuFilter($items, $filters['sku_id']);
});
}
}
return $query;
}
private const SORT_COLUMNS = ['refundDate', '_source_rank', '_head_id', '_source_id', '_item_id'];
private function orderedKeys(array $filters, ?object $cursor, int $limit): Builder
{
$branches = [];
foreach ([false, true] as $supplement) {
$rank = $supplement ? 1 : 0;
$headColumn = $supplement ? 'r.as_id' : 'r.Id';
$heads = $this->sourceQuery($filters, $supplement, false)
->select(['r.as_date', 'r.as_id'])->when(! $supplement, fn (Builder $q) => $q->addSelect('r.Id'));
if ($cursor !== null) {
$heads->where(function (Builder $after) use ($cursor, $rank, $headColumn, $supplement, $filters): void {
$after->where('r.as_date', '<', $cursor->refundDate);
if ($rank <= (int) $cursor->_source_rank) {
$after->orWhere(function (Builder $sameDate) use ($cursor, $rank, $headColumn, $supplement, $filters): void {
$sameDate->where('r.as_date', $cursor->refundDate);
if ($rank === (int) $cursor->_source_rank) {
$headValue = $supplement ? $cursor->_source_id : $cursor->_head_id;
$sameDate->where(function (Builder $head) use ($headColumn, $headValue, $cursor, $supplement, $filters): void {
$head->where($headColumn, '<', $headValue)
->orWhere(function (Builder $remaining) use ($headColumn, $headValue, $cursor, $supplement, $filters): void {
$remaining->where($headColumn, $headValue)->whereExists(function (Builder $items) use ($cursor, $supplement, $filters): void {
$items->selectRaw('1')->from(($supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item') . ' as i')
->whereColumn('i.as_id', 'r.as_id')
->where($supplement ? 'i.asi_id' : 'i.Id', '<', $cursor->_item_id);
if ($filters['sku_id'] !== '') {
$this->applySkuFilter($items, $filters['sku_id']);
}
});
});
});
}
});
}
});
}
$heads->orderByDesc('r.as_date')->orderByDesc($headColumn)->limit($limit);
// Each eligible header contributes at least one row. The newest N
// headers therefore contain the newest N matching detail rows.
$source = $this->connection->query()->fromSub($heads, 'r')
->leftJoin(($supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item') . ' as i', 'i.as_id', '=', 'r.as_id');
if ($filters['sku_id'] !== '') {
$this->applySkuFilter($source, $filters['sku_id']);
}
$source->selectRaw($supplement
? 'r.as_date AS refundDate, 1 AS _source_rank, 0 AS _head_id, COALESCE(i.asi_id, 0) AS _item_id, r.as_id AS _source_id'
: "r.as_date AS refundDate, 0 AS _source_rank, r.Id AS _head_id, COALESCE(i.Id, 0) AS _item_id, '' AS _source_id");
$branch = $this->connection->query()->fromSub($source, 'source_keys');
if ($cursor !== null) {
$branch->where(function (Builder $after) use ($cursor): void {
foreach (self::SORT_COLUMNS as $position => $column) {
$after->orWhere(function (Builder $part) use ($cursor, $position, $column): void {
foreach (array_slice(self::SORT_COLUMNS, 0, $position) as $equal) {
$part->where($equal, '=', $cursor->$equal);
}
$part->where($column, '<', $cursor->$column);
});
}
});
}
foreach (self::SORT_COLUMNS as $column) {
$branch->orderByDesc($column);
}
$branches[] = $this->connection->query()->fromSub($branch->limit($limit), 'bounded_keys');
}
// Bound both branches before the final merge, including cursor exports.
$query = $this->connection->query()->fromSub($branches[0]->unionAll($branches[1]), 'refund_keys');
foreach (self::SORT_COLUMNS as $column) {
$query->orderByDesc($column);
}
return $query;
}
private function loadRows($keys)
{
$loaded = collect();
foreach ([false, true] as $supplement) {
$sourceKeys = $keys->filter(fn (object $key): bool => (bool) $key->_source_rank === $supplement);
if ($sourceKeys->isEmpty()) {
continue;
}
$table = $supplement ? 'ops_jst_qmcust_refunds' : 'ops_jst_return_refund_info';
$itemTable = $supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item';
$itemKey = $supplement ? 'i.asi_id' : 'i.Id';
$query = $this->connection->table($table . ' as r')
->leftJoin($itemTable . ' as i', 'i.as_id', '=', 'r.as_id')
->whereIn($supplement ? 'r.as_id' : 'r.Id', $sourceKeys->pluck($supplement ? '_source_id' : '_head_id'))
->where(function (Builder $items) use ($sourceKeys, $itemKey): void {
$items->whereIn($itemKey, $sourceKeys->pluck('_item_id'));
if ($sourceKeys->contains(fn (object $key): bool => (int) $key->_item_id === 0)) {
$items->orWhereNull($itemKey);
}
});
if ($supplement) {
$query->select([
'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id',
'r.o_id', 'r.so_id', 'r.type', 'r.node', 'r.shop_type', 'r.shop_name',
'r.refund as totalRefundMoney', 'i.outer_oi_id', 'i.shop_sku_id', 'i.i_id', 'i.sku_id',
'i.qty', 'i.shop_amount as detailRefundMoney', 'i.type as refundType',
'i.r_qty as refundQty', 'r.buyer_apply_refund as AfSalesMoney',
'r.local_created as createTime', 'r.status', 'r.shop_status', 'r.created', 'r.confirm_date',
'r.shop_id',
])->selectRaw("COALESCE(i.asi_id, r.as_id) AS Id, NULL AS RefundMoneyIncome, '退款清单' AS dataSource, 1 AS _source_rank, 0 AS _head_id, COALESCE(i.asi_id, 0) AS _item_id, r.as_id AS _source_id");
} else {
$query->select($this->selectColumns())
->selectRaw("COALESCE(i.Id, r.Id) AS Id, '售后资料' AS dataSource, 0 AS _source_rank, r.Id AS _head_id, COALESCE(i.Id, 0) AS _item_id, '' AS _source_id");
}
$loaded = $loaded->concat($query->get());
}
$byIdentity = $loaded->keyBy(fn (object $row): string => $this->identity($row));
$rows = $keys->map(fn (object $key) => $byIdentity->get($this->identity($key)))->filter()->values();
$shopIds = $rows->where('_source_rank', 1)->pluck('shop_id')->unique();
$platforms = $shopIds->isEmpty() ? collect() : $this->connection->table('ops_jst_shop_info')
->whereIn('shop_id', $shopIds)->get(['shop_id', 'shop_site'])->groupBy('shop_id');
foreach ($rows as $row) {
if ((int) $row->_source_rank === 1) {
$sites = ($platforms->get($row->shop_id) ?? collect())->pluck('shop_site')->filter()->unique()->values();
$row->platform = $sites->count() === 1 ? $sites->first() : '待确认';
unset($row->shop_id);
}
$row->detailStatus = (int) $row->_item_id === 0 ? '暂无商品明细' : '已关联';
foreach (['totalRefundMoney', 'detailRefundMoney', 'RefundMoneyIncome', 'AfSalesMoney'] as $money) {
if (! is_numeric($row->$money)) {
$row->$money = null;
}
}
foreach (array_slice(self::SORT_COLUMNS, 1) as $column) {
unset($row->$column);
}
}
$this->hydratePage($rows);
return $rows;
}
private function identity(object $row): string
{
return implode('|', [$row->_source_rank, $row->_head_id, $row->_item_id, $row->_source_id]);
}
/** @param array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} $filters */
@ -235,27 +440,19 @@ class NonJingxiRefundMirrorRepository
$query->where('r.as_date', '>=', $filters['from'] . ' 00:00:00')
->where('r.as_date', '<', Carbon::parse($filters['to'])->addDay()->format('Y-m-d 00:00:00'));
if ($filters['order_no'] !== '') {
$needle = $this->contains($filters['order_no']);
$query->where(function (Builder $order) use ($needle): void {
$order->whereRaw("CAST(r.o_id AS CHAR) LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("r.so_id LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("r.outer_as_id LIKE ? ESCAPE '!'", [$needle]);
});
}
if ($filters['shop_name'] !== '') {
$query->whereRaw("r.shop_name LIKE ? ESCAPE '!'", [$this->contains($filters['shop_name'])]);
}
}
if ($filters['sku_id'] !== '') {
$needle = $this->contains($filters['sku_id']);
$query->where(function (Builder $sku) use ($needle): void {
$sku->whereRaw("i.sku_id LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("i.shop_sku_id LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("i.i_id LIKE ? ESCAPE '!'", [$needle]);
});
}
private function applySkuFilter(Builder $query, string $value): void
{
$needle = $this->contains($value);
$query->where(function (Builder $sku) use ($needle): void {
$sku->whereRaw("i.sku_id LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("i.shop_sku_id LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("i.i_id LIKE ? ESCAPE '!'", [$needle]);
});
}
/** @param \Illuminate\Support\Collection<int,object> $rows */
@ -265,13 +462,14 @@ class NonJingxiRefundMirrorRepository
return;
}
$orderIds = $rows->pluck('o_id')->filter()->unique()->values()->all();
$primaryRows = $rows->where('dataSource', '售后资料');
$orderIds = $primaryRows->pluck('o_id')->filter()->unique()->values()->all();
$skuIds = $rows->pluck('sku_id')->filter()->unique()->values()->all();
$shopNames = $rows->pluck('shop_name')->filter()->unique()->values()->all();
$orderItems = $this->auxiliaryReady(self::AUXILIARY_DATASETS[1])
? $this->connection->table('ops_jst_order_item')
->whereIn('o_id', $orderIds)
->whereIn('outer_oi_id', $rows->pluck('outer_oi_id')->filter()->unique()->all())
->whereIn('outer_oi_id', $primaryRows->pluck('outer_oi_id')->filter()->unique()->all())
->get(['o_id', 'outer_oi_id', 'shop_i_id', 'shop_sku_id', 'i_id', 'sku_id', 'buyer_paid_amount'])
->keyBy(static fn (object $row): string => $row->o_id . '|' . $row->outer_oi_id)
: collect();
@ -291,14 +489,14 @@ class NonJingxiRefundMirrorRepository
: collect();
foreach ($rows as $row) {
$orderItem = $orderItems->get($row->o_id . '|' . $row->outer_oi_id);
$orderItem = $row->dataSource === '售后资料' ? $orderItems->get($row->o_id . '|' . $row->outer_oi_id) : null;
$skuId = $row->sku_id ?: ($orderItem->sku_id ?? null);
$unitCost = isset($skus[$skuId]) ? (float) $skus[$skuId]->cost_price : null;
$refundQuantity = $row->refundQty === null ? null : (float) $row->refundQty;
$row->isZC = str_contains((string) $row->shop_type, '自营') ? '是' : '否';
$row->responsibleDepartment = $shops[$row->shop_name]->responsibleDepartment ?? null;
$row->originPostcode = $orders[$row->o_id]->receiver_zip ?? null;
$row->originPostcode = $row->dataSource === '售后资料' ? ($orders[$row->o_id]->receiver_zip ?? null) : null;
$row->shop_i_id = $orderItem->shop_i_id ?? null;
$row->shop_sku_id = $row->shop_sku_id ?: ($orderItem->shop_sku_id ?? null);
$row->i_id = $row->i_id ?: ($orderItem->i_id ?? null);

View File

@ -141,6 +141,7 @@ class ProductCatalogMirrorRepository
$this->attachWorkPlatformPrices($rows, $workPlatformPriceSyncStatus['initialized']);
$jushuitanCostPriceHistorySyncStatus = $this->jushuitanCostPriceHistorySyncStatus();
$this->attachJushuitanCostPriceHistory($rows, $jushuitanCostPriceHistorySyncStatus['initialized']);
app(JushuitanCostPriceObservationRepository::class)->attach($rows);
return [
'data' => $rows,
@ -571,6 +572,7 @@ class ProductCatalogMirrorRepository
$query = $this->connection->table($productTable)
->where('sku_type', 'normal')
->select([
'_mirror_key',
'autoid',
'i_id',
'sku_id',

View File

@ -88,6 +88,63 @@ class SupplierManagementMirrorRepository
];
}
public function supplierById(int $id): ?object
{
if (! $this->ready() || $id <= 0) {
return null;
}
return $this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable())
->where('Id', $id)
->first(['Id', 'scmcode', 'scmname']);
}
/** @param list<string> $codes
* @return list<object>
*/
public function suppliersByCodes(array $codes): array
{
$codes = array_values(array_unique(array_filter(
array_map('trim', $codes),
static fn (string $code): bool => $code !== '',
)));
if ($codes === []) {
return [];
}
if (! $this->ready()) {
throw new RuntimeException('supplier_management_mirror_not_ready');
}
return $this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable())
->whereIn('scmcode', $codes)
->orderBy('Id')
->get(['Id', 'scmcode', 'scmname'])
->all();
}
/** @param list<string> $names
* @return list<object>
*/
public function suppliersByNames(array $names): array
{
$names = array_values(array_unique(array_filter(
array_map('trim', $names),
static fn (string $name): bool => $name !== '',
)));
if ($names === []) {
return [];
}
if (! $this->ready()) {
throw new RuntimeException('supplier_management_mirror_not_ready');
}
return $this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable())
->whereIn('scmname', $names)
->orderBy('Id')
->get(['Id', 'scmcode', 'scmname'])
->all();
}
/** 全部筛选结果,按上游稳定键小批读取,保留编码/账号文本。 */
public function exportRows(array $filters): \Generator
{

View File

@ -139,6 +139,13 @@ class BusinessMirrorSchemaManager
$length = (int) ($column['max_length'] ?? 0);
$precision = min(max((int) ($column['numeric_precision'] ?? 20), 1), 65);
$scale = min(max((int) ($column['numeric_scale'] ?? 0), 0), min($precision, 30));
// 超宽表(例如商品利润 160 多个 nvarchar 列)即使每列只有 50 字符,
// 全部按 VARCHAR 建表也会超过 InnoDB 行长度上限;这类数据集把非键列存为 TEXT。
$wideStrings = (bool) app(BusinessMirrorDatasetRegistry::class)
->definitionValue($dataset->key(), 'wide_strings', false);
$requiredInline = in_array(strtolower($name), array_map('strtolower', $dataset->keyColumns()), true)
|| strcasecmp((string) $dataset->watermarkColumn(), $name) === 0
|| $this->isConfiguredIndexColumn($dataset, $name);
$definition = match ($type) {
'bit', 'boolean' => $table->boolean($name),
@ -160,6 +167,7 @@ class BusinessMirrorSchemaManager
// may opt a bounded legacy identifier into VARCHAR; every other
// wide string remains TEXT and is stored off-page.
'char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying' => $length > 0
&& ! ($wideStrings && ! $requiredInline)
&& ($length <= 64 || ($length <= 255 && $this->isConfiguredIndexColumn($dataset, $name)))
? $table->string($name, $length)
: $table->longText($name),

View File

@ -0,0 +1,207 @@
<?php
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountType;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 财务台账账户总表空白录入模板。
*/
class FinanceLedgerAccountTemplate
{
private const MAX_ROWS = 1000;
private const HEADERS = [
'公司名称',
'账户简称',
'账户类型',
'账户状态',
'地区',
'账号',
'开户行',
'开户城市/网点',
'公司规划',
'账户是否保留',
'后4位',
'单笔限额',
'日限额',
'月限额',
'年限额',
'可用金额',
'不可用金额',
'每日可出',
'预计出清天数',
'提额目标',
'提额状态',
'提额动作',
'已做动作',
'下一步',
'备注',
'责任人',
];
public function download(): StreamedResponse
{
$spreadsheet = $this->makeSpreadsheet();
return response()->streamDownload(function () use ($spreadsheet): void {
$writer = new Xlsx($spreadsheet);
$writer->setPreCalculateFormulas(false);
$writer->save('php://output');
$spreadsheet->disconnectWorksheets();
}, '财务台账-账户总表录入模板.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'no-store, no-cache, must-revalidate',
]);
}
private function makeSpreadsheet(): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getProperties()
->setTitle('财务台账账户总表录入模板')
->setSubject('账户总表重新录入');
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('账户总表');
$lastColumn = Coordinate::stringFromColumnIndex(count(self::HEADERS));
$lastRow = self::MAX_ROWS + 1;
foreach (self::HEADERS as $index => $header) {
$column = Coordinate::stringFromColumnIndex($index + 1);
$sheet->setCellValue("{$column}1", $header);
$sheet->getColumnDimension($column)->setWidth($this->width($index));
}
$sheet->freezePane('A2');
$sheet->setAutoFilter("A1:{$lastColumn}1");
$sheet->getRowDimension(1)->setRowHeight(28);
$sheet->getStyle("A1:{$lastColumn}1")->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER)
->setWrapText(true);
$sheet->getStyle("A1:{$lastColumn}1")->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
$sheet->getStyle("A1:{$lastColumn}1")->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FF856632');
$sheet->getStyle("A1:{$lastColumn}{$lastRow}")->getBorders()->getAllBorders()
->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFD8D2C7');
$sheet->getStyle("A2:O{$lastRow}")->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
$sheet->getStyle("P2:R{$lastRow}")->getNumberFormat()->setFormatCode('#,##0.00');
$sheet->getStyle("S2:S{$lastRow}")->getNumberFormat()->setFormatCode('0.00');
$sheet->getStyle("T2:Z{$lastRow}")->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
$options = $spreadsheet->createSheet();
$options->setTitle('选项');
$options->setCellValue('A1', '账户状态');
$options->setCellValue('B1', '账户类型');
$options->setCellValue('C1', '账户是否保留');
$statuses = array_values(FinanceLedgerAccountStatus::OPTIONS);
$types = $this->accountTypes();
$keepOptions = FinanceLedgerAccountShouldKeep::OPTIONS;
foreach ($statuses as $index => $status) {
$options->setCellValue('A' . ($index + 2), $status);
}
foreach ($types as $index => $type) {
$options->setCellValue('B' . ($index + 2), $type);
}
foreach ($keepOptions as $index => $option) {
$options->setCellValue('C' . ($index + 2), $option);
}
$options->setSheetState(Worksheet::SHEETSTATE_HIDDEN);
$this->applyListValidation($sheet, 'D', '\'选项\'!$A$2:$A$' . (count($statuses) + 1), '请选择账户状态');
if ($types !== []) {
$this->applyListValidation($sheet, 'C', '\'选项\'!$B$2:$B$' . (count($types) + 1), '请选择账户类型');
}
$this->applyListValidation($sheet, 'J', '\'选项\'!$C$2:$C$' . (count($keepOptions) + 1), '请选择账户是否保留');
$notes = $spreadsheet->createSheet();
$notes->setTitle('填写说明');
$notes->fromArray([
['项目', '说明'],
['用途', '账户总表重新录入模板,不包含当前账户数据。'],
['必填', '公司名称、账户简称。'],
['账户状态', implode(' / ', $statuses)],
['账户类型', $types !== [] ? implode(' / ', $types) : '留空或按实际填写'],
['账户是否保留', implode(' / ', $keepOptions)],
['金额', '可用金额、不可用金额、每日可出按数字填写;限额列保留原有组合写法。'],
['提交前', '请核对账户数、余额合计和期初合计,再导入系统。'],
], null, 'A1');
$notes->getColumnDimension('A')->setWidth(16);
$notes->getColumnDimension('B')->setWidth(70);
$notes->getStyle('A1:B1')->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
$notes->getStyle('A1:B1')->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FF856632');
$notes->getStyle('A1:B8')->getAlignment()->setVertical(Alignment::VERTICAL_TOP)->setWrapText(true);
$notes->getStyle('A1:B8')->getBorders()->getAllBorders()
->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFD8D2C7');
$notes->getRowDimension(1)->setRowHeight(26);
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
private function applyListValidation(
Worksheet $sheet,
string $column,
string $formula,
string $prompt,
): void {
$validation = $sheet->getCell("{$column}2")->getDataValidation();
$validation->setType(DataValidation::TYPE_LIST);
$validation->setErrorStyle(DataValidation::STYLE_STOP);
$validation->setAllowBlank(true);
$validation->setShowInputMessage(true);
$validation->setPromptTitle('选择' . $prompt);
$validation->setPrompt($prompt);
$validation->setShowErrorMessage(true);
$validation->setErrorTitle('选项无效');
$validation->setError($prompt);
$validation->setFormula1($formula);
$validation->setShowDropDown(true);
for ($row = 2; $row <= self::MAX_ROWS + 1; $row++) {
$sheet->getCell("{$column}{$row}")->setDataValidation(clone $validation);
}
}
private function accountTypes(): array
{
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$base = $connection->table('fin_accounts')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->pluck('account_type')->all();
$overrides = $connection->table('fin_account_type_overrides')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->pluck('account_type')->all();
return FinanceLedgerAccountType::options($base, $overrides);
}
private function width(int $index): int
{
return match ($index) {
0, 6 => 30,
1, 5, 7, 24 => 22,
2, 3, 4, 8, 9, 18 => 13,
15, 16, 17 => 16,
default => 18,
};
}
}

View File

@ -2,7 +2,10 @@
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountType;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use RuntimeException;
@ -10,8 +13,8 @@ use RuntimeException;
/**
* 导入财务台账的基础数据:账户、店铺、公司和期初余额。
*
* 源文件是财务维护的《银行当前余额-*.xlsx》。这里只做结构校验和全量
* 覆盖,不推断业务口径;导入完成后由 FinanceLedgerCalculator 重算流水。
* 源文件是财务维护的《银行当前余额-*.xlsx》。这里只做结构校验、后4位
* 规范化和全量覆盖,不推断其他业务口径;导入完成后由计算器重算流水。
*/
class FinanceLedgerBaseImporter
{
@ -90,45 +93,69 @@ class FinanceLedgerBaseImporter
];
}
private function accounts(?Worksheet $sheet): array
protected function accounts(?Worksheet $sheet): array
{
if (! $sheet) {
throw new RuntimeException('缺少「账户总表」工作表。');
}
$lastColumn = Coordinate::columnIndexFromString($sheet->getHighestDataColumn());
$headers = $sheet->rangeToArray('A1:' . Coordinate::stringFromColumnIndex($lastColumn) . '1')[0] ?? [];
$headerIndexes = [];
foreach ($headers as $index => $header) {
$key = (string) preg_replace('/\s+/u', '', trim((string) $header));
if ($key !== '') {
$headerIndexes[$key] = $index;
}
}
$rows = [];
foreach ($this->rows($sheet, 2, 23) as $row) {
$company = $this->text($row[0] ?? null);
$short = $this->text($row[1] ?? null);
foreach ($this->rows($sheet, 2, $lastColumn) as $row) {
$value = static function (string $header, int $legacyIndex) use ($row, $headerIndexes): mixed {
return $row[$headerIndexes[$header] ?? $legacyIndex] ?? null;
};
$company = $this->text($value('公司名称', 0));
$short = $this->text($value('账户简称', 1));
if ($company === '' || $short === '') {
continue;
}
$keepIndex = $headerIndexes['账户是否保留'] ?? null;
$accountNo = $this->text($value('账号', 2));
$accountType = $this->text($value('账户类型', 5));
$rows[] = [
'company_name' => $company,
'account_short' => $short,
'region' => null,
'account_no' => $this->text($row[2] ?? null),
'bank_name' => $this->text($row[3] ?? null),
'bank_city_branch' => $this->text($row[4] ?? null),
'account_type' => $this->text($row[5] ?? null),
'company_plan' => $this->text($row[6] ?? null),
'account_last4' => $this->text($row[7] ?? null),
'single_limit' => $this->text($row[8] ?? null),
'daily_limit' => $this->text($row[9] ?? null),
'monthly_limit' => $this->text($row[10] ?? null),
'yearly_limit' => $this->text($row[11] ?? null),
'current_balance' => $this->number($row[12] ?? null),
'blocked_amount' => $this->number($row[13] ?? null),
'daily_available' => $this->number($row[14] ?? null),
'clearing_days' => $this->nullableNumber($row[15] ?? null),
'raise_target' => $this->text($row[16] ?? null),
'raise_status' => $this->text($row[17] ?? null),
'raise_actions' => $this->text($row[18] ?? null),
'done_actions' => $this->text($row[19] ?? null),
'next_action' => $this->text($row[20] ?? null),
'remark' => $this->text($row[21] ?? null),
'owner' => $this->text($row[22] ?? null),
'account_no' => $accountNo,
'bank_name' => $this->text($value('开户行', 3)),
'bank_city_branch' => $this->text($value('开户城市/网点', 4)),
'account_type' => $accountType,
'company_plan' => $this->text($value('公司规划', 6)),
'account_should_keep' => FinanceLedgerAccountShouldKeep::normalize(
$keepIndex === null ? null : $this->text($row[$keepIndex] ?? null),
),
'account_last4' => FinanceLedgerAccountType::last4(
$accountType,
$accountNo,
$this->text($value('后4位', 7)),
),
'single_limit' => $this->text($value('单笔限额', 8)),
'daily_limit' => $this->text($value('日限额', 9)),
'monthly_limit' => $this->text($value('月限额', 10)),
'yearly_limit' => $this->text($value('年限额', 11)),
'current_balance' => $this->number($value('可用金额', 12)),
'blocked_amount' => $this->number($value('不可用金额', 13)),
'daily_available' => $this->number($value('每日可出', 14)),
'clearing_days' => $this->nullableNumber($value('预计出清天数', 15)),
'raise_target' => $this->text($value('提额目标', 16)),
'raise_status' => $this->text($value('提额状态', 17)),
'raise_actions' => $this->text($value('提额动作', 18)),
'done_actions' => $this->text($value('已做动作', 19)),
'next_action' => $this->text($value('下一步', 20)),
'remark' => $this->text($value('备注', 21)),
'owner' => $this->text($value('责任人', 22)),
];
}

View File

@ -2,6 +2,7 @@
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountStatus;
use Illuminate\Support\Facades\DB;
/**
@ -10,7 +11,8 @@ use Illuminate\Support\Facades\DB;
* 计算口径固定在服务端,页面只读结果:
* 期末 = 期初 + 当日入账 - 当日出账
* 账户当前余额 = 最新一条流水期末
* 每日可出金额 = 当前余额 - 卡住金额
* 正常账户:可用金额 = 当前余额,不可用金额 = 0
* 异常账户:可用金额 = 0,不可用金额 = 当前余额
* 店铺余额 = 店铺所属公司名下账户当前余额合计(待财务确认)
*/
class FinanceLedgerCalculator
@ -150,11 +152,12 @@ class FinanceLedgerCalculator
->where('account_short', $account)
->update([
'current_balance' => round((float) $balance, 2),
'daily_available' => $connection->raw('ROUND(current_balance - blocked_amount, 2)'),
'updated_at' => $now,
]);
}
$this->refreshAccountAmounts();
$companies = $connection->table('fin_accounts')
->selectRaw('company_name, SUM(current_balance) AS balance')
->groupBy('company_name')
@ -175,6 +178,58 @@ class FinanceLedgerCalculator
];
}
/**
* 按账户状态回填可用金额和不可用金额。
*
* 正常账户:可用金额 = 当前余额,不可用金额 = 0;
* 异常账户:可用金额 = 0,不可用金额 = 当前余额。
*/
public function refreshAccountAmounts(?string $accountShort = null): int
{
$connection = $this->connection();
if ($accountShort !== null) {
$status = $connection->table('fin_account_statuses')
->where('account_short', $accountShort)
->value('status');
$normal = $status === null
|| trim((string) $status) === ''
|| $status === FinanceLedgerAccountStatus::NORMAL;
return $connection->table('fin_accounts')
->where('account_short', $accountShort)
->update([
'daily_available' => $normal ? $connection->raw('current_balance') : 0,
'blocked_amount' => $normal ? 0 : $connection->raw('current_balance'),
'updated_at' => now(),
]);
}
$abnormalAccounts = $connection->table('fin_account_statuses')
->where('status', '<>', FinanceLedgerAccountStatus::NORMAL)
->pluck('account_short')
->filter()
->unique()
->values()
->all();
$normalCount = $connection->table('fin_accounts')->update([
'daily_available' => $connection->raw('current_balance'),
'blocked_amount' => 0,
'updated_at' => now(),
]);
if ($abnormalAccounts === []) {
return $normalCount;
}
return $normalCount + $connection->table('fin_accounts')
->whereIn('account_short', $abnormalAccounts)
->update([
'daily_available' => 0,
'blocked_amount' => $connection->raw('current_balance'),
'updated_at' => now(),
]);
}
private function connection()
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));

View File

@ -0,0 +1,150 @@
<?php
namespace Modules\EcommerceFinance\Services;
use App\Models\Auth\User;
use Illuminate\Support\Facades\DB;
/**
* 按账户总表批量建立“供应商应付”公司名称到账户简称的映射。
*
* 公司只有唯一账户,或虽有多个账户但只有一个公户时自动关联;
* 多公户、只有私人卡等歧义情况保留人工选择。
*/
class FinanceLedgerPayableMappingBulk
{
public function preview(): array
{
$connection = $this->connection();
$accountsByCompany = [];
foreach ($connection->table('fin_accounts')->get(['company_name', 'account_short', 'account_type']) as $account) {
$company = trim((string) $account->company_name);
$accountShort = trim((string) $account->account_short);
if ($company === '' || $accountShort === '') {
continue;
}
$accountsByCompany[$company][$accountShort] = [
'account_short' => $accountShort,
'account_type' => trim((string) $account->account_type),
];
}
$rows = $connection->table('fin_account_map')
->where('source', 'payable')
->where(function ($query): void {
$query->whereNull('account_short')->orWhere('account_short', '');
})
->orderBy('source_value')
->get(['id', 'source_value']);
$candidates = [];
$ambiguous = [];
$missing = [];
foreach ($rows as $row) {
$company = trim((string) $row->source_value);
$accounts = array_values($accountsByCompany[$company] ?? []);
if ($accounts === []) {
$missing[] = $company;
continue;
}
if (count($accounts) === 1) {
$candidates[] = $this->candidate($row, $accounts[0]['account_short'], 'single_account');
continue;
}
$publicAccounts = array_values(array_filter(
$accounts,
static fn (array $account): bool => $account['account_type'] === '公户',
));
if (count($publicAccounts) === 1) {
$candidates[] = $this->candidate($row, $publicAccounts[0]['account_short'], 'single_public_account');
continue;
}
$ambiguous[] = [
'company' => $company,
'accounts' => array_values(array_map(
static fn (array $account): string => $account['account_short'],
$accounts,
)),
];
}
return [
'total' => $rows->count(),
'eligible' => count($candidates),
'ambiguous' => count($ambiguous),
'missing' => count($missing),
'candidates' => $candidates,
'ambiguous_values' => $ambiguous,
'missing_values' => $missing,
];
}
public function apply(?User $user = null): array
{
$preview = $this->preview();
$connection = $this->connection();
$userName = (string) ($user?->name ?? '');
$userId = (int) ($user?->id ?? 0);
$now = now();
$mapped = 0;
if ($preview['candidates'] !== []) {
$connection->transaction(function () use ($connection, $preview, $userName, $userId, $now, &$mapped): void {
foreach ($preview['candidates'] as $candidate) {
$updated = $connection->table('fin_account_map')
->where('id', $candidate['id'])
->where(function ($query): void {
$query->whereNull('account_short')->orWhere('account_short', '');
})
->update([
'account_short' => $candidate['account_short'],
'updated_by' => $userName,
'updated_at' => $now,
]);
if ($updated !== 1) {
continue;
}
$connection->table('fin_account_map_events')->insert([
'source' => 'payable',
'source_value' => $candidate['source_value'],
'old_account_short' => null,
'new_account_short' => $candidate['account_short'],
'user_id' => $userId,
'user_name' => $userName,
'created_at' => $now,
]);
$mapped++;
}
});
}
if ($mapped > 0) {
app(FinanceLedgerCalculator::class)->recalculate();
}
return [
'total' => $preview['total'],
'mapped' => $mapped,
'ambiguous' => $preview['ambiguous'],
'missing' => $preview['missing'],
];
}
private function candidate(object $row, string $accountShort, string $matchType): array
{
return [
'id' => (int) $row->id,
'source_value' => trim((string) $row->source_value),
'account_short' => $accountShort,
'match_type' => $matchType,
];
}
private function connection()
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
}

View File

@ -47,13 +47,14 @@ class FinanceLedgerRptSynchronizer
) {
}
public function sync(bool $recalculate = true, ?string $from = null, ?string $to = null): array
public function sync(bool $recalculate = true, ?string $from = null, ?string $to = null, bool $merge = false): array
{
$from = trim((string) ($from ?? config('finance_ledger.window_start', '2026-04-18')));
$to = trim((string) ($to ?? config('finance_ledger.window_end', ''))) ?: now()->toDateString();
$lookback = $merge ? $from : self::DING_SINCE;
$transactions = array_merge(
$this->dingTalkRows(),
$this->dingTalkRows($lookback, $to),
$this->workOrderRows(),
$this->payableRows($from),
$this->storeOpeningRows($from),
@ -69,42 +70,40 @@ class FinanceLedgerRptSynchronizer
throw new RuntimeException('rpt 没有返回窗口内的动账明细。');
}
$result = $this->writer->replace($transactions, $recalculate);
$result = $merge
? $this->writer->merge($transactions, $recalculate)
: $this->writer->replace($transactions, $recalculate);
$result['source_counts'] = array_count_values(array_column($transactions, 'process_type'));
return $result;
}
/** @return array<int,array<string,mixed>> */
private function dingTalkRows(): array
private function dingTalkRows(string $from, string $to): array
{
$processCodes = array_keys((array) config('finance_ledger.dingtalk_process_codes', []));
if ($processCodes === []) {
throw new RuntimeException('未配置钉钉审批流程编码。');
}
$placeholders = implode(',', array_fill(0, count($processCodes), '?'));
$query = $this->queries->forSource(self::SOURCE);
$query = $this->query();
$workflows = $query->select(
'SELECT processInstanceId, processCode, businessId, title, status, originatorUserId, '
. 'originatorDeptName, joinAt, finishTime FROM rpt.ding_Workflow '
. 'WHERE joinAt >= ? AND processCode IN (' . $placeholders . ')',
[self::DING_SINCE, ...$processCodes],
. 'originatorDeptName, joinAt, finishTime FROM rpt.fin_ledger_dingtalk_workflow '
. 'WHERE joinAt >= ? AND joinAt < ?',
[$from, \Carbon\CarbonImmutable::parse($to)->addDay()->toDateString()],
);
if ($workflows === []) {
return [];
}
$schemes = $query->select(
'SELECT s.id, s.processInstanceId, s.name, CAST(s.value AS VARCHAR(MAX)) AS value '
. 'FROM rpt.ding_WorkflowScheme s JOIN rpt.ding_Workflow w ON w.processInstanceId = s.processInstanceId '
. 'WHERE w.joinAt >= ? AND w.processCode IN (' . $placeholders . ') '
. "AND s.name IN ('付款明细','收款费用明细','应付明细','固定资产报废或出售明细','收款合计','付款金额合计') "
. 'ORDER BY s.id',
[self::DING_SINCE, ...$processCodes],
);
$schemes = [];
foreach (array_chunk(array_values(array_unique(array_column($workflows, 'processInstanceId'))), 100) as $ids) {
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$batch = $query->select(
'SELECT s.id, s.processInstanceId, s.name, s.value FROM rpt.fin_ledger_dingtalk_scheme s '
. 'WHERE s.processInstanceId IN (' . $placeholders . ') ORDER BY s.id',
$ids,
);
array_push($schemes, ...$batch);
}
$employees = [];
foreach ($query->select('SELECT userid, name FROM rpt.hr_employee') as $employee) {
@ -260,10 +259,8 @@ class FinanceLedgerRptSynchronizer
/** @return array<int,array<string,mixed>> */
private function workOrderRows(): array
{
$rows = $this->queries->forSource(self::SOURCE)->select(
'SELECT Id, woNo, createby, created, completeTime, businessFieldList FROM rpt.jst_workOrderInfo '
. 'WHERE woTypeId = ? AND status = ?',
[self::JST_WORK_ORDER_TYPE, 'CONFIRMED'],
$rows = $this->query()->select(
'SELECT Id, woNo, createby, created, completeTime, businessFieldList FROM rpt.fin_ledger_workorder',
);
$result = [];
@ -319,7 +316,7 @@ class FinanceLedgerRptSynchronizer
. 'FROM detail d JOIN rpt.biz_bank_receipt_detail r ON r.DetailId = d.id AND r.TaskId = d.business_no '
. "WHERE d.receipt_pay_status IN (N'打款中',N'打款完成')";
$rows = $this->queries->forSource(self::SOURCE)->select($sql);
$rows = $this->query()->select($sql);
$result = [];
foreach ($rows as $row) {
$amount = $this->toNumber($row['amountLower'] ?? null);
@ -354,14 +351,11 @@ class FinanceLedgerRptSynchronizer
/** @return array<int,array<string,mixed>> */
private function storeOpeningRows(string $from): array
{
$sql = 'SELECT a.flowId, a.AccountCode, a.AccountName, a.costType, a.money, a.paymentTime, '
. 'a.paymentAccount, b.erpShopName, b.platform, b.applyUser, b.applyDepartment '
. 'FROM rpt.fys_shopInfo_kingdeeCode_flow a '
. 'LEFT JOIN rpt.fys_shopInfo_flow b ON a.flowId = b.flowId '
. 'WHERE a.flowId IN (SELECT flowId FROM rpt.flow_flow WHERE flowType = 8 AND flowStatus = 3) '
. 'AND a.type = 1 AND a.paymentTime >= ? AND a.isPaymentMade = 1 AND a.money > 0';
$sql = 'SELECT flowId, AccountCode, AccountName, costType, money, paymentTime, '
. 'paymentAccount, erpShopName, platform, applyUser, applyDepartment '
. 'FROM rpt.fin_ledger_store_opening WHERE paymentTime >= ?';
$rows = $this->queries->forSource(self::SOURCE)->select($sql, [$from]);
$rows = $this->query()->select($sql, [$from]);
$seen = [];
$result = [];
foreach ($rows as $row) {
@ -414,38 +408,76 @@ class FinanceLedgerRptSynchronizer
/** @return array<int,array<string,mixed>> */
private function withdrawalRows(string $from): array
{
$sql = 'WITH d AS ('
. 'SELECT a.Id, a.shop_id, a.channel, a.fee_time, a.fee_name, a.inout_type, a.amount, a.primary_key, '
. 'ROW_NUMBER() OVER (PARTITION BY a.primary_key ORDER BY a.Id) AS rn '
. "FROM rpt.jst_originalBillInfo a WHERE a.fee_name LIKE N'%提现%' AND a.fee_time >= ?"
. ') SELECT d.Id, d.channel, d.fee_time, d.fee_name, d.amount, b.shop_name, d.inout_type '
. 'FROM d LEFT JOIN rpt.jst_shopInfo b ON d.shop_id = b.shop_id WHERE d.rn = 1';
$rows = $this->queries->forSource(self::SOURCE)->select($sql, [$from]);
$query = $this->query();
$result = [];
foreach ($rows as $row) {
if ((string) ($row['inout_type'] ?? '') !== '-1') {
continue;
$seen = [];
// 视图已经按 fee_time + inout_type 建了过滤索引;这里按月份拉取,
// 应用侧按 primary_key 去重,避免视图内 ROW_NUMBER 全量排序。
foreach ($this->monthWindows($from, now()->toDateString()) as [$windowStart, $windowEnd]) {
$rows = $query->select(
'SELECT Id, shop_name, channel, fee_time, fee_name, amount, primary_key '
. 'FROM rpt.fin_ledger_withdrawal WHERE fee_time >= ? AND fee_time < ?',
[$windowStart, $windowEnd],
);
foreach ($rows as $row) {
$primaryKey = trim((string) ($row['primary_key'] ?? ''));
$dedupeKey = $primaryKey !== '' ? $primaryKey : 'ID:' . (string) ($row['Id'] ?? '');
if (isset($seen[$dedupeKey])) {
continue;
}
$seen[$dedupeKey] = true;
$shop = trim((string) ($row['shop_name'] ?? ''));
$channel = self::CHANNEL_NAMES[(string) ($row['channel'] ?? '')] ?? (string) ($row['channel'] ?? '');
$result[] = $this->row([
'date' => $this->date($row['fee_time'] ?? null),
'account_short' => $shop,
'source_value' => $shop,
'source' => 'shop',
'process_type' => '店铺提现',
'approval_no' => (string) ($row['Id'] ?? ''),
'detail' => $this->joinParts([$shop, $channel, trim((string) ($row['fee_name'] ?? ''))]),
'income' => $this->toNumber($row['amount'] ?? null),
'expense' => 0,
'pid' => 'BILL-' . (string) ($row['Id'] ?? ''),
]);
}
$shop = trim((string) ($row['shop_name'] ?? ''));
$channel = self::CHANNEL_NAMES[(string) ($row['channel'] ?? '')] ?? (string) ($row['channel'] ?? '');
$result[] = $this->row([
'date' => $this->date($row['fee_time'] ?? null),
'account_short' => $shop,
'source_value' => $shop,
'source' => 'shop',
'process_type' => '店铺提现',
'approval_no' => (string) ($row['Id'] ?? ''),
'detail' => $this->joinParts([$shop, $channel, trim((string) ($row['fee_name'] ?? ''))]),
'income' => $this->toNumber($row['amount'] ?? null),
'expense' => 0,
'pid' => 'BILL-' . (string) ($row['Id'] ?? ''),
]);
}
return $result;
}
/** @return array<int,array{0:string,1:string}> */
private function monthWindows(string $from, string $to): array
{
if ($from === '' || $to === '' || $from > $to) {
return [];
}
$cursor = \Carbon\CarbonImmutable::parse($from)->startOfMonth();
$end = \Carbon\CarbonImmutable::parse($to)->addDay()->startOfDay();
$windows = [];
while ($cursor->lessThan($end) && count($windows) < 240) {
$next = $cursor->addMonth();
$upper = $next->lessThan($end) ? $next : $end;
$windows[] = [$cursor->toDateString(), $upper->toDateString()];
$cursor = $next;
}
return $windows;
}
private function query(): UpstreamReadOnlyQuery
{
return $this->queries->forSourceWithTimeout(
self::SOURCE,
(int) config('finance_ledger.query_timeout', 120),
);
}
private function row(array $data): array
{
return [

View File

@ -0,0 +1,117 @@
<?php
namespace Modules\EcommerceFinance\Services;
use App\Models\Auth\User;
use Illuminate\Support\Facades\DB;
/**
* 按店铺台账批量建立“店铺提现”来源值到账户简称的映射。
*
* 只处理店铺名称完全一致、回款账户简称存在于账户总表的未映射记录,
* 其余来源值保留人工确认。
*/
class FinanceLedgerShopMappingBulk
{
public function preview(): array
{
$connection = $this->connection();
$shopAccounts = $connection->table('fin_shops')
->whereNotNull('account_short')
->where('account_short', '<>', '')
->where('account_short', '<>', '/')
->pluck('account_short', 'shop_name');
$masterAccounts = array_fill_keys(
$connection->table('fin_accounts')->distinct()->pluck('account_short')->all(),
true,
);
$rows = $connection->table('fin_account_map')
->where('source', 'shop')
->where(function ($query): void {
$query->whereNull('account_short')->orWhere('account_short', '');
})
->orderBy('source_value')
->get(['id', 'source_value']);
$candidates = [];
$unmatched = [];
foreach ($rows as $row) {
$sourceValue = trim((string) $row->source_value);
$accountShort = trim((string) ($shopAccounts[$sourceValue] ?? ''));
if ($accountShort === '' || ! isset($masterAccounts[$accountShort])) {
$unmatched[] = $sourceValue;
continue;
}
$candidates[] = [
'id' => (int) $row->id,
'source_value' => $sourceValue,
'account_short' => $accountShort,
];
}
return [
'total' => $rows->count(),
'matched' => count($candidates),
'unmatched' => count($unmatched),
'candidates' => $candidates,
'unmatched_values' => $unmatched,
];
}
public function apply(?User $user = null): array
{
$preview = $this->preview();
$connection = $this->connection();
$userName = (string) ($user?->name ?? '');
$userId = (int) ($user?->id ?? 0);
$now = now();
$mapped = 0;
if ($preview['candidates'] !== []) {
$connection->transaction(function () use ($connection, $preview, $userName, $userId, $now, &$mapped): void {
foreach ($preview['candidates'] as $candidate) {
$updated = $connection->table('fin_account_map')
->where('id', $candidate['id'])
->where(function ($query): void {
$query->whereNull('account_short')->orWhere('account_short', '');
})
->update([
'account_short' => $candidate['account_short'],
'updated_by' => $userName,
'updated_at' => $now,
]);
if ($updated !== 1) {
continue;
}
$connection->table('fin_account_map_events')->insert([
'source' => 'shop',
'source_value' => $candidate['source_value'],
'old_account_short' => null,
'new_account_short' => $candidate['account_short'],
'user_id' => $userId,
'user_name' => $userName,
'created_at' => $now,
]);
$mapped++;
}
});
}
if ($mapped > 0) {
app(FinanceLedgerCalculator::class)->recalculate();
}
return [
'total' => $preview['total'],
'mapped' => $mapped,
'unmatched' => $preview['unmatched'],
];
}
private function connection()
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
}

View File

@ -14,6 +14,17 @@ use RuntimeException;
class FinanceLedgerTransactionWriter
{
public function replace(array $transactions, bool $recalculate = true): array
{
return $this->persist($transactions, $recalculate, true);
}
/** 只更新/插入本次快照,不删除历史明细;用于每日增量。 */
public function merge(array $transactions, bool $recalculate = true): array
{
return $this->persist($transactions, $recalculate, false);
}
private function persist(array $transactions, bool $recalculate, bool $replaceExisting): array
{
if ($transactions === []) {
throw new RuntimeException('没有可写入的动账明细。');
@ -74,12 +85,23 @@ class FinanceLedgerTransactionWriter
throw new RuntimeException('动账明细没有有效日期。');
}
$connection->transaction(function () use ($connection, $rows, $now): void {
$connection->table('fin_transactions')->delete();
foreach (array_chunk($rows, 500) as $chunk) {
$connection->table('fin_transactions')->insert($chunk);
$connection->transaction(function () use ($connection, $rows, $now, $replaceExisting): void {
if ($replaceExisting) {
$connection->table('fin_transactions')->delete();
}
$this->refreshMappingStats($connection, $now);
foreach (array_chunk($rows, 500) as $chunk) {
if ($replaceExisting) {
$connection->table('fin_transactions')->insert($chunk);
} else {
$connection->table('fin_transactions')->upsert(
$chunk,
['row_key'],
['txn_date', 'account_short', 'detail', 'income', 'expense', 'approval_no',
'source', 'source_value', 'process_type', 'synced_at', 'updated_at'],
);
}
}
$this->refreshMappingStats($connection, $now, $replaceExisting);
});
$result = [
@ -97,7 +119,7 @@ class FinanceLedgerTransactionWriter
return $result;
}
private function refreshMappingStats($connection, $now): void
private function refreshMappingStats($connection, $now, bool $pruneStale = true): void
{
$stats = $connection->table('fin_transactions')
->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum')
@ -125,14 +147,16 @@ class FinanceLedgerTransactionWriter
// 上游删除或改写的来源值不能继续留在映射表里;只清理本次未出现的行,
// 保留已有 account_short,避免重同步覆盖人工映射。
$stale = [];
foreach ($connection->table('fin_account_map')->get(['id', 'source', 'source_value']) as $existing) {
if (! isset($keys[(string) $existing->source . "\0" . (string) $existing->source_value])) {
$stale[] = (int) $existing->id;
if ($pruneStale) {
$stale = [];
foreach ($connection->table('fin_account_map')->get(['id', 'source', 'source_value']) as $existing) {
if (! isset($keys[(string) $existing->source . "\0" . (string) $existing->source_value])) {
$stale[] = (int) $existing->id;
}
}
foreach (array_chunk($stale, 500) as $ids) {
$connection->table('fin_account_map')->whereIn('id', $ids)->delete();
}
}
foreach (array_chunk($stale, 500) as $ids) {
$connection->table('fin_account_map')->whereIn('id', $ids)->delete();
}
foreach (array_chunk($rows, 500) as $chunk) {

View File

@ -0,0 +1,137 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
use Throwable;
/** 当前镜像的观测记录;不写正式操作日志,也不推测真实改价时间或历史价格。 */
class JushuitanCostPriceSnapshotDiffer
{
public const DATASET_KEY = 'jushuitan.product-cost-price-observations';
public const SNAPSHOTS = 'jushuitan_cost_price_observation_baselines';
public const OBSERVATIONS = 'jushuitan_cost_price_observations';
public const STATES = 'business_mirror_table_states';
public function __construct(
private readonly GenericBusinessMirrorRepository $mirror,
private readonly BusinessMirrorDatasetRegistry $datasets,
) {}
public function sync(int $batchSize = 500, int $maxBatches = 20): array
{
$lock = Cache::lock('jushuitan-cost-price-observations', 1800);
if (! $lock->get()) {
return ['status' => 'locked', 'rows' => 0, 'changes' => 0];
}
try {
return $this->run(max(1, min(1000, $batchSize)), max(1, min(2000, $maxBatches)));
} finally {
$lock->release();
}
}
private function run(int $batchSize, int $maxBatches): array
{
$db = $this->mirror->connection();
$schema = Schema::connection($db->getName());
$dataset = $this->datasets->get(ProductCatalogMirrorRepository::DATASET_KEY);
if (! $this->mirror->ready($dataset)
|| ! $schema->hasTable(self::SNAPSHOTS) || ! $schema->hasTable(self::OBSERVATIONS)) {
return ['status' => 'source_not_ready', 'rows' => 0, 'changes' => 0];
}
$source = $this->mirror->state($dataset);
if (($source['status'] ?? '') !== 'succeeded') {
return ['status' => 'source_not_ready', 'rows' => 0, 'changes' => 0];
}
$state = $db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->first();
$resume = $state && in_array($state->status, ['running', 'failed'], true);
$cursor = $resume ? json_decode($state->cursor ?? 'null', true) : null;
// 固定窗口、两分钟重叠,兼容源镜像批次时间精度;重复读取不重复记变价。
$from = $resume ? $state->window_started_at
: ($state?->window_ends_at ? Carbon::parse($state->window_ends_at)->subMinutes(2)->format('Y-m-d H:i:s.u') : null);
$until = $resume ? $state->window_ends_at : now()->subSeconds(2)->format('Y-m-d H:i:s.u');
$db->table(self::STATES)->updateOrInsert(['dataset_key' => self::DATASET_KEY], [
'source_key' => 'operations_replica', 'source_object' => 'mirror.' . $dataset->targetTable(),
'target_table' => self::OBSERVATIONS, 'status' => 'running',
'run_mode' => $state?->initial_completed_at ? 'incremental' : 'baseline',
'generation' => $resume ? $state->generation : (string) Str::uuid(),
'window_started_at' => $from, 'window_ends_at' => $until,
'cursor' => $cursor ? json_encode($cursor) : null,
'last_attempted_at' => now(), 'last_error_code' => null, 'updated_at' => now(),
]);
$scanned = $changes = 0;
try {
for ($batch = 0; $batch < $maxBatches; $batch++) {
$query = $db->table($dataset->targetTable())
->select(['_mirror_key', '_mirror_synced_at', 'sku_id', 'cost_price', 'modified'])
->where('sku_type', 'normal')->whereNotNull('cost_price')
->where('_mirror_synced_at', '<=', $until);
if ($from !== null) {
$query->where('_mirror_synced_at', '>=', $from);
}
if ($cursor) {
$query->where(static fn ($q) => $q->where('_mirror_synced_at', '>', $cursor['at'])
->orWhere(static fn ($q) => $q->where('_mirror_synced_at', $cursor['at'])->where('_mirror_key', '>', $cursor['key'])));
}
$rows = $query->orderBy('_mirror_synced_at')->orderBy('_mirror_key')->limit($batchSize)->get();
if ($rows->isEmpty()) {
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
'status' => 'succeeded', 'cursor' => null, 'high_watermark' => json_encode(['at' => $until]),
'initial_completed_at' => $state?->initial_completed_at ?? now(),
'last_succeeded_at' => now(), 'updated_at' => now(),
]);
return ['status' => 'succeeded', 'rows' => $scanned, 'changes' => $changes];
}
$previous = $db->table(self::SNAPSHOTS)->whereIn('source_key', $rows->pluck('_mirror_key'))->get()->keyBy('source_key');
$snapshots = $events = [];
$observedAt = now()->format('Y-m-d H:i:s.u');
foreach ($rows as $row) {
$sku = trim((string) $row->sku_id);
if ($sku === '' || ! is_numeric($row->cost_price)) {
continue;
}
$price = number_format((float) $row->cost_price, 4, '.', '');
$old = $previous->get($row->_mirror_key);
if ($old && $old->sku_id === $sku && number_format((float) $old->cost_price, 4, '.', '') !== $price) {
$events[] = [
'id' => hash('sha256', $row->_mirror_key . '|' . $old->captured_at . '|' . $observedAt . '|' . $price),
'source_key' => $row->_mirror_key, 'sku_id' => $sku,
'before_price' => $old->cost_price, 'after_price' => $price,
'previous_observed_at' => $old->captured_at, 'observed_at' => $observedAt,
'source_modified_at' => $row->modified,
];
}
$snapshots[] = ['source_key' => $row->_mirror_key, 'sku_id' => $sku, 'cost_price' => $price, 'captured_at' => $observedAt];
}
$last = $rows->last();
$cursor = ['at' => $last->_mirror_synced_at, 'key' => $last->_mirror_key];
// 价格基线、观测事件和游标同一事务;中断续传不会漏写或重复记账。
$db->transaction(function () use ($db, $snapshots, $events, $cursor): void {
if ($snapshots) {
$db->table(self::SNAPSHOTS)->upsert($snapshots, ['source_key'], ['sku_id', 'cost_price', 'captured_at']);
}
if ($events) {
$db->table(self::OBSERVATIONS)->insert($events);
}
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
'cursor' => json_encode($cursor), 'updated_at' => now(),
]);
});
$scanned += $rows->count();
$changes += count($events);
}
return ['status' => 'running', 'rows' => $scanned, 'changes' => $changes];
} catch (Throwable $exception) {
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
'status' => 'failed', 'last_error_code' => 'snapshot_comparison_failed', 'updated_at' => now(),
]);
throw $exception;
}
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace Modules\EcommerceFinance\Services;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
/** 财务端货款对账比对汇总 CSV。 */
class SupplierComparisonCsvExporter
{
private const HEADERS = [
'采购单号', '日期', '我方商品编码', '厂家条码', '比对结果',
'我方数量', '我方单价', '我方金额',
'厂家数量', '厂家单价', '厂家金额',
'数量差异', '金额差异', '结算口径', '结算金额',
];
/**
* @param array<string,mixed> $detail
*/
public function download(
array $detail,
string $supplier,
string $month,
string $reconciler,
): StreamedResponse {
$safeSupplier = preg_replace('/[\\\\\\/:*?"<>|]+/u', '_', $supplier) ?: '厂家';
$period = $month !== '' ? preg_replace('/[^0-9-]+/u', '', $month) : '全部月份';
$filename = $safeSupplier . '_货款对账比对_' . $period . '_' . now()->format('Ymd_His') . '.csv';
$summary = (array) ($detail['summary'] ?? []);
$rows = (array) ($detail['rows'] ?? []);
return response()->streamDownload(function () use ($detail, $rows, $summary, $supplier, $month, $reconciler): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new RuntimeException('无法创建导出文件。');
}
fwrite($csv, "\xEF\xBB\xBF");
$this->write($csv, ['供应商', $supplier, '月份', $month !== '' ? $month : '全部月份', '对账人', $reconciler !== '' ? $reconciler : '全部']);
$this->write($csv, [
'我方数据行数', (int) ($detail['mineRows'] ?? 0),
'厂家数据行数', (int) ($detail['theirsRows'] ?? 0),
'比对结果条数', count($rows),
]);
$this->write($csv, [
'双方一致', (int) ($summary['match'] ?? 0),
'厂家多', (int) ($summary['supplierExtra'] ?? 0),
'数量/金额不一致', (int) ($summary['mismatch'] ?? 0),
'厂家少', (int) ($summary['supplierMissing'] ?? 0),
]);
$this->write($csv, [
'我方金额合计', $this->money($summary['mineAmount'] ?? 0),
'厂家金额合计', $this->money($summary['theirsAmount'] ?? 0),
'厂家多算金额', $this->money($summary['supplierOverAmount'] ?? 0),
'按口径结算合计', $this->money($summary['settleAmount'] ?? 0),
'差异金额', $this->money($summary['differenceAmount'] ?? 0),
]);
$this->write($csv, []);
$this->write($csv, self::HEADERS);
foreach ($rows as $row) {
$this->write($csv, [
$row['purchaseNo'] ?? '',
$row['date'] ?? '',
$row['barcode'] ?? '',
$row['supplierBarcode'] ?? '',
$row['type'] ?? '',
$this->number($row['mineQty'] ?? 0),
$this->money($row['mineUnitPrice'] ?? 0),
$this->money($row['mineAmount'] ?? 0),
$this->number($row['theirsQty'] ?? 0),
$this->money($row['theirsUnitPrice'] ?? 0),
$this->money($row['theirsAmount'] ?? 0),
$this->quantityDifference($row['qtyDifference'] ?? 0),
$this->amountDifference($row['amountDifference'] ?? 0),
$row['note'] ?? '',
$this->money($row['settleAmount'] ?? 0),
]);
}
fclose($csv);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @param resource $csv @param list<mixed> $values */
private function write($csv, array $values): void
{
fputcsv($csv, array_map(fn (mixed $value): string => $this->csvCell($value), $values));
}
private function quantityDifference(mixed $value): string
{
$difference = (float) $value;
if (abs($difference) < 0.005) {
return '一致';
}
return '厂家' . ($difference > 0 ? '多 ' : '少 ') . $this->number(abs($difference));
}
private function amountDifference(mixed $value): string
{
$difference = (float) $value;
if (abs($difference) < 0.005) {
return '一致';
}
return '厂家' . ($difference > 0 ? '多 ¥' : '少 ¥') . $this->money(abs($difference));
}
private function number(mixed $value): string
{
return rtrim(rtrim(number_format((float) $value, 2, '.', ''), '0'), '.');
}
private function money(mixed $value): string
{
return number_format((float) $value, 2, '.', '');
}
private function csvCell(mixed $value): string
{
$text = trim((string) $value);
if (is_numeric(str_replace(',', '', $text))) {
return $text;
}
return preg_match('/^[=+\-@\t\r]/u', $text) === 1 ? "\t" . $text : $text;
}
}

View File

@ -165,7 +165,7 @@ class SupplierDingTalkNotifier
'- 对账月份:' . ($month !== '' ? $month : '全部月份'),
'- 对账人:' . ($reconciler !== '' ? $reconciler : '全部'),
'- 差异共 **' . $summary['count'] . '** 条(厂家多 ' . $summary['extra']
. ' / 金额数量不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
. ' / 数量金额不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
'- 差异金额:**¥' . number_format($summary['difference_amount'], 2) . '**',
];
@ -179,22 +179,29 @@ class SupplierDingTalkNotifier
$theirsQty = (float) ($row['theirsQty'] ?? 0);
$mineAmount = (float) ($row['mineAmount'] ?? 0);
$theirsAmount = (float) ($row['theirsAmount'] ?? 0);
$mineUnitPrice = (float) ($row['mineUnitPrice'] ?? $row['unitPrice'] ?? 0);
$theirsUnitPrice = (float) ($row['theirsUnitPrice'] ?? $row['unitPrice'] ?? 0);
$purchaseNo = trim((string) ($row['purchaseNo'] ?? ''));
$date = trim((string) ($row['date'] ?? ''));
$barcode = trim((string) ($row['barcode'] ?? ''));
$supplierBarcode = trim((string) ($row['supplierBarcode'] ?? ''));
$lines[] = sprintf(
'%d. **%s** `%s`%s 单价 ¥%s|我方 %s / ¥%s|厂家 %s / ¥%s|%s',
'%d. **%s**|采购单号:%s|日期:%s|商品编码:%s%s',
$rowOffset + $index + 1,
(string) ($row['type'] ?? '差异'),
$purchaseNo !== '' ? $purchaseNo : '—',
$date !== '' ? $date : '—',
$barcode,
$supplierBarcode !== '' ? '(厂家条码 ' . $supplierBarcode . ')' : '',
number_format((float) ($row['unitPrice'] ?? 0), 2),
$this->number($mineQty),
number_format($mineAmount, 2),
$this->number($theirsQty),
number_format($theirsAmount, 2),
(string) ($row['note'] ?? ''),
);
$lines[] = ' 我方:数量 ' . $this->number($mineQty)
. '|单价 ¥' . number_format($mineUnitPrice, 2)
. '|金额 ¥' . number_format($mineAmount, 2);
$lines[] = ' 厂家:数量 ' . $this->number($theirsQty)
. '|单价 ¥' . number_format($theirsUnitPrice, 2)
. '|金额 ¥' . number_format($theirsAmount, 2);
$lines[] = ' 处理:' . (string) ($row['note'] ?? '');
}
$lines[] = '';

View File

@ -0,0 +1,101 @@
<?php
namespace Modules\EcommerceFinance\Services;
/**
* 供应商是否包邮厂家的本地补充资料。
*
* 供应商主档来自只读共享镜像,包邮标记保存在现有供应商维护表中,
* 以供应商镜像的稳定 ID 关联,不写回上游。
*/
class SupplierFreeShippingStore
{
public const DATASET = 'supplier-management.free-shipping-manufacturer';
public const FIELD = 'free_shipping_manufacturer';
/** @var array<int,array<string,array<string,mixed>>> */
private array $cache = [];
public function __construct(private readonly SupplierPayableOverrideStore $overrides)
{
}
/** @param list<object> $rows
* @return list<object>
*/
public function decorateRows(array $rows, int $companyId): array
{
foreach ($rows as $row) {
$this->decorateRow($row, $companyId);
}
return $rows;
}
public function decorateRow(object $row, int $companyId): object
{
$id = (int) ($row->Id ?? 0);
$values = $this->values($companyId);
$row->{self::FIELD} = $id > 0 && (bool) ($values[$this->key($id)][self::FIELD] ?? false);
return $row;
}
/** @return array{created:int,updated:int} */
public function save(int $supplierId, bool $isFreeShipping, int $companyId, ?int $userId, string $source = 'manual'): array
{
$result = $this->overrides->save(
self::DATASET,
$this->key($supplierId),
[self::FIELD => $isFreeShipping],
$companyId,
$userId,
$source,
);
unset($this->cache[$companyId]);
return $result;
}
/**
* @param array<int,bool> $values 供应商 ID => 是否包邮厂家
* @return array{created:int,updated:int}
*/
public function saveMany(array $values, int $companyId, ?int $userId, string $source = 'import'): array
{
$records = [];
foreach ($values as $supplierId => $isFreeShipping) {
if ((int) $supplierId <= 0) {
continue;
}
$records[$this->key((int) $supplierId)] = [self::FIELD => (bool) $isFreeShipping];
}
$result = $this->overrides->saveBatch(self::DATASET, $records, $companyId, $userId, $source);
unset($this->cache[$companyId]);
return $result;
}
/** @return array<string,array<string,mixed>> */
private function values(int $companyId): array
{
if (! isset($this->cache[$companyId])) {
$this->cache[$companyId] = [];
foreach ($this->overrides->listRows(self::DATASET, $companyId) as $row) {
$key = (string) ($row['_action_key'] ?? '');
if ($key !== '') {
$this->cache[$companyId][$key] = $row;
}
}
}
return $this->cache[$companyId];
}
private function key(int $supplierId): string
{
return 'supplier:' . $supplierId;
}
}

View File

@ -0,0 +1,200 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
/** 读取和生成「是否包邮厂家」导入文件。 */
class SupplierFreeShippingWorkbook
{
public const SUPPLIER_CODE_HEADER = '供应商代码';
public const FIELD_HEADER = '是否包邮厂家';
/**
* 返回供应商代码 => 是否包邮厂家。空白或“-”表示否。
*
* @return array<string,bool>
*/
public function read(UploadedFile $file): array
{
try {
$reader = IOFactory::createReader(match (mb_strtolower($file->getClientOriginalExtension())) {
'xlsx' => 'Xlsx',
'xls' => 'Xls',
'csv' => 'Csv',
default => throw new \RuntimeException('Unsupported workbook format.'),
});
$book = $reader->load($file->getRealPath());
} catch (Throwable) {
throw ValidationException::withMessages([
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
]);
}
try {
[$sheet, $headerRow, $codeColumn, $fieldColumn] = $this->locateColumns($book);
$highestRow = $sheet->getHighestDataRow();
if ($highestRow > 5001) {
throw ValidationException::withMessages(['import' => '单次最多导入 5,000 条供应商记录,请拆分文件后重试。']);
}
$records = [];
$lineByCode = [];
for ($row = $headerRow + 1; $row <= $highestRow; $row++) {
$code = $this->text($sheet, $codeColumn, $row);
if (str_starts_with($code, "'")) {
$code = substr($code, 1);
}
$field = $this->text($sheet, $fieldColumn, $row);
if ($code === '' && $field === '') {
continue;
}
if ($code === '') {
throw ValidationException::withMessages(['import' => "第 {$row} 行供应商代码为空。"]);
}
if (mb_strlen($code) > 191) {
throw ValidationException::withMessages(['import' => "第 {$row} 行供应商代码超过 191 个字符。"]);
}
$codeKey = mb_strtolower($code);
if (isset($lineByCode[$codeKey])) {
throw ValidationException::withMessages([
'import' => "第 {$row} 行供应商代码“{$code}”与第 {$lineByCode[$codeKey]} 行重复。",
]);
}
$records[$code] = $this->parseFlag($field, $row);
$lineByCode[$codeKey] = $row;
}
if ($records === []) {
throw ValidationException::withMessages(['import' => '表格中没有可导入的供应商记录。']);
}
return $records;
} finally {
$book->disconnectWorksheets();
}
}
public function template(): StreamedResponse
{
return response()->streamDownload(function (): void {
$book = new Spreadsheet();
$sheet = $book->getActiveSheet();
$sheet->setTitle('是否包邮厂家');
$sheet->fromArray([self::SUPPLIER_CODE_HEADER, '供应商名称', self::FIELD_HEADER], null, 'A1');
$sheet->getStyle('A1:C1')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'D8D2C7']]],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter('A1:C1');
foreach (['A' => 22, 'B' => 34, 'C' => 20] as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->getStyle('A2:A5001')->getNumberFormat()->setFormatCode('@');
$instructions = $book->createSheet();
$instructions->setTitle('填写说明');
$instructions->fromArray([
['项目', '说明'],
[self::SUPPLIER_CODE_HEADER, '必填。请填写供应商管理页面中的供应商代码,按代码精确关联。'],
[self::FIELD_HEADER, '填写“包邮”表示是包邮厂家;空白或“-”表示不是包邮厂家。'],
['兼容导出表', '也可以使用本页面导出的文件,在此列补充后直接导入。'],
['覆盖规则', '同一供应商再次导入会更新该字段;其他列不会修改。'],
], null, 'A1');
$instructions->getStyle('A1:B1')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
]);
$instructions->getColumnDimension('A')->setWidth(22);
$instructions->getColumnDimension('B')->setWidth(76);
$instructions->getStyle('A1:B5')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true);
foreach (range(1, 5) as $row) {
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 24 : 34);
}
$book->setActiveSheetIndex(0);
(new Xlsx($book))->save('php://output');
$book->disconnectWorksheets();
}, '供应商是否包邮厂家_导入模板_' . now()->format('Ymd') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array{0:Worksheet,1:int,2:int,3:int} */
private function locateColumns(Spreadsheet $book): array
{
$best = null;
foreach ($book->getWorksheetIterator() as $sheet) {
$rows = $sheet->toArray(null, false, false, false);
foreach (array_slice($rows, 0, 20, true) as $rowIndex => $row) {
$headers = [];
foreach ($row as $columnIndex => $value) {
$header = $this->header($value);
if ($header !== '') {
$headers[$header] = $columnIndex + 1;
}
}
if (isset($headers[self::SUPPLIER_CODE_HEADER], $headers[self::FIELD_HEADER])) {
$candidate = [$sheet, $rowIndex + 1, $headers[self::SUPPLIER_CODE_HEADER], $headers[self::FIELD_HEADER]];
$best = $candidate;
break 2;
}
}
}
if ($best === null) {
throw ValidationException::withMessages([
'import' => '缺少表头:需要“供应商代码”和“是否包邮厂家”两列。',
]);
}
return $best;
}
private function parseFlag(string $value, int $row): bool
{
$normalized = mb_strtolower(preg_replace('/[\s\x{FEFF}]+/u', '', $value));
if (in_array($normalized, ['', '-', '否', '不是', '0', 'false', 'no'], true)) {
return false;
}
if (in_array($normalized, ['包邮', '是', '是包邮厂家', '1', 'true', 'yes'], true)) {
return true;
}
throw ValidationException::withMessages([
'import' => "第 {$row} 行“是否包邮厂家”只能填写“包邮”,或留空表示否。",
]);
}
private function text(Worksheet $sheet, int $column, int $row): string
{
$cell = $sheet->getCell([$column, $row]);
if ($cell->getDataType() === 'f') {
throw ValidationException::withMessages(['import' => "第 {$row} 行含公式,请填写实际值。"]);
}
return trim((string) $cell->getFormattedValue());
}
private function header(mixed $value): string
{
return str_replace([' ', ' ', "\n", "\r", "\t", "\x{FEFF}"], '', trim((string) $value));
}
}

View File

@ -4,9 +4,9 @@ namespace Modules\EcommerceFinance\Services;
/**
* 厂家对账单 vs 我方结算数据:
* - 行键 = 条码(去掉厂家编号/版本后缀,如 CJ019312N1097V01 → CJ019312)+ 单价
* (同一把钥匙的多行先合并:数量、金额相加);
* - 四类结果:一致 / 厂家多(不结算、退回)/ 金额不一致(按金额低的一方结算)/ 厂家少(按厂家数据结算,即不付款)。
* - 行键 = 采购单号 + 日期 + 我方商品编码(对应厂家条码去掉厂家编号/版本后缀);
* - 同一行键的多行先合并数量与金额,再比较双方数量和金额;
* - 厂家未填采购单号时,仅在同日期+商品编码能唯一对应我方一条数据时兜底匹配。
*/
class SupplierStatementMatcher
{
@ -14,7 +14,7 @@ class SupplierStatementMatcher
public const TYPE_SUPPLIER_EXTRA = '厂家多';
public const TYPE_MISMATCH = '金额不一致';
public const TYPE_MISMATCH = '数量/金额不一致';
public const TYPE_SUPPLIER_MISSING = '厂家少';
@ -22,74 +22,129 @@ class SupplierStatementMatcher
private const SUPPLIER_SUFFIX = '/[A-Z]{1,3}\d{3,6}V\d{1,3}$/i';
/**
* @param list<array<string,mixed>> $mine 我方结算数据(货款对账:skuCode/unitPrice/qty/amount)
* @param list<array<string,mixed>> $theirs 厂家对账单(barcode/unitPrice/qty/amount)
* @param list<array<string,mixed>> $mine 我方数据(purchaseNo/date/productCode/unitPrice/qty/amount;productCode 为空才回退货号)
* @param list<array<string,mixed>> $theirs 厂家数据(purchaseNo/createdAt/barcode/unitPrice/qty/amount)
* @return array{rows:list<array<string,mixed>>,summary:array<string,mixed>}
*/
public function compare(array $mine, array $theirs): array
{
$mineGrouped = $this->group($mine, 'skuCode');
$theirsGrouped = $this->group($theirs, 'barcode');
$mineGrouped = $this->group($mine, ['productCode', 'skuCode'], 'purchaseNo', 'date');
$theirsGrouped = $this->group($theirs, ['barcode'], 'purchaseNo', 'createdAt');
// 已有采购单号时严格按三元组匹配;缺失采购单号时,仅在日期+商品编码唯一时兜底。
$fallbackIndex = [];
foreach ($mineGrouped as $key => $row) {
$fallbackIndex[$row['date'] . "\0" . $row['barcodeKey']][] = $key;
}
$theirsGrouped = $this->resolveMissingPurchaseNumbers($theirsGrouped, $fallbackIndex);
$rows = [];
$summary = ['match' => 0, 'supplierExtra' => 0, 'mismatch' => 0, 'supplierMissing' => 0,
'mineAmount' => 0.0, 'settleAmount' => 0.0];
$summary = [
'match' => 0,
'supplierExtra' => 0,
'mismatch' => 0,
'supplierMissing' => 0,
'mineAmount' => 0.0,
'theirsAmount' => 0.0,
'supplierOverQty' => 0.0,
'supplierOverAmount' => 0.0,
'settleAmount' => 0.0,
];
foreach (array_unique(array_merge(array_keys($mineGrouped), array_keys($theirsGrouped))) as $key) {
$mineRow = $mineGrouped[$key] ?? null;
$theirsRow = $theirsGrouped[$key] ?? null;
$barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode']);
$supplierBarcode = (string) ($theirsRow['barcode'] ?? '');
$unitPrice = (float) ($mineRow['unitPrice'] ?? $theirsRow['unitPrice']);
$purchaseNo = (string) ($mineRow['purchaseNo'] ?? $theirsRow['purchaseNo'] ?? '');
$date = (string) ($mineRow['date'] ?? $theirsRow['date'] ?? '');
$barcode = (string) ($mineRow['barcode'] ?? $theirsRow['barcode'] ?? '');
$supplierBarcode = (string) ($theirsRow['supplierBarcode'] ?? '');
$mineUnitPrice = (float) ($mineRow['unitPrice'] ?? 0);
$theirsUnitPrice = (float) ($theirsRow['unitPrice'] ?? 0);
$unitPrice = $mineUnitPrice > 0 ? $mineUnitPrice : $theirsUnitPrice;
$mineQty = (float) ($mineRow['qty'] ?? 0);
$mineAmount = (float) ($mineRow['amount'] ?? 0);
$theirsQty = (float) ($theirsRow['qty'] ?? 0);
$theirsAmount = (float) ($theirsRow['amount'] ?? 0);
$qtyDifference = $theirsQty - $mineQty;
$amountDifference = $theirsAmount - $mineAmount;
$quantityDifference = $this->describeDifference($qtyDifference, $amountDifference);
$fallbackNote = '';
if (($theirsRow['matchedByFallback'] ?? false) === true) {
$fallbackNote = '厂家未填采购单号,已按日期+商品编码唯一匹配;';
} elseif (($theirsRow['fallbackAmbiguous'] ?? false) === true) {
$fallbackNote = '厂家未填采购单号,且同日期+商品编码有多条我方数据,无法唯一匹配;';
}
if ($mineRow === null) {
$type = self::TYPE_SUPPLIER_EXTRA;
$settle = 0.0;
$note = '我方没有这条数据:不结算,退回厂家修改账单';
$note = $fallbackNote . '厂家整条多报:' . $quantityDifference . ';我方没有该采购单号+日期+商品编码,不结算';
$summary['supplierExtra']++;
} elseif ($theirsRow === null) {
$type = self::TYPE_SUPPLIER_MISSING;
$settle = 0.0;
$note = '厂家少报了这条数据:按厂家数据结算(不付款)';
$note = '厂家漏报:我方数量 ' . $this->number($mineQty)
. ',单价 ¥' . number_format($mineUnitPrice, 2)
. ',金额 ¥' . number_format($mineAmount, 2)
. ';按厂家数据结算(不付款)';
$summary['supplierMissing']++;
} elseif (abs($mineAmount - $theirsAmount) < 0.005 && abs($mineQty - $theirsQty) < 0.005) {
$type = self::TYPE_MATCH;
$settle = $mineAmount;
$note = '正常结算';
$note = $fallbackNote . '采购单号、日期、商品编码、数量和金额一致,正常结算';
$summary['match']++;
} else {
$type = self::TYPE_MISMATCH;
$settle = min($mineAmount, $theirsAmount);
$note = '金额/数量不一致:按金额低的一方结算';
$note = $fallbackNote . $quantityDifference . ';按金额低的一方结算,请厂家修改';
$summary['mismatch']++;
}
$summary['mineAmount'] += $mineAmount;
$summary['theirsAmount'] += $theirsAmount;
$summary['supplierOverQty'] += max($qtyDifference, 0);
$summary['supplierOverAmount'] += max($amountDifference, 0);
$summary['settleAmount'] += $settle;
$rows[] = [
'purchaseNo' => $purchaseNo,
'date' => $date,
'barcode' => $barcode,
'supplierBarcode' => $supplierBarcode === $barcode ? '' : $supplierBarcode,
'supplierBarcode' => $supplierBarcode !== '' && $supplierBarcode !== $barcode ? $supplierBarcode : '',
'unitPrice' => $unitPrice,
'mineUnitPrice' => $mineUnitPrice,
'theirsUnitPrice' => $theirsUnitPrice,
'mineQty' => $mineQty,
'mineAmount' => $mineAmount,
'theirsQty' => $theirsQty,
'theirsAmount' => $theirsAmount,
'qtyDifference' => round($qtyDifference, 4),
'amountDifference' => round($amountDifference, 2),
'supplierOverQty' => round(max($qtyDifference, 0), 4),
'supplierOverAmount' => round(max($amountDifference, 0), 2),
'type' => $type,
'settleAmount' => $settle,
'note' => $note,
];
}
usort($rows, static fn (array $a, array $b): int => [$a['type'] === self::TYPE_MATCH ? 1 : 0, $a['barcode']]
<=> [$b['type'] === self::TYPE_MATCH ? 1 : 0, $b['barcode']]);
usort($rows, static fn (array $a, array $b): int => [
$a['type'] === self::TYPE_MATCH ? 1 : 0,
$a['date'],
$a['purchaseNo'],
$a['barcode'],
] <=> [
$b['type'] === self::TYPE_MATCH ? 1 : 0,
$b['date'],
$b['purchaseNo'],
$b['barcode'],
]);
$summary['mineAmount'] = round($summary['mineAmount'], 2);
$summary['theirsAmount'] = round($summary['theirsAmount'], 2);
$summary['supplierOverQty'] = round($summary['supplierOverQty'], 4);
$summary['supplierOverAmount'] = round($summary['supplierOverAmount'], 2);
$summary['settleAmount'] = round($summary['settleAmount'], 2);
$summary['differenceAmount'] = round($summary['mineAmount'] - $summary['settleAmount'], 2);
@ -97,36 +152,176 @@ class SupplierStatementMatcher
}
/**
* 按「条码 + 单价」归组,数量与金额相加。
* 按「采购单号 + 日期 + 商品编码」归组,数量与金额相加。
*
* @param list<array<string,mixed>> $rows
* @return array<string,array{barcode:string,unitPrice:float,qty:float,amount:float}>
* @param list<string> $barcodeFields
* @return array<string,array<string,mixed>>
*/
private function group(array $rows, string $barcodeField): array
private function group(array $rows, array $barcodeFields, string $purchaseField, string $dateField): array
{
$grouped = [];
foreach ($rows as $row) {
$barcode = trim((string) ($row[$barcodeField] ?? ''));
$barcode = $this->barcodeValue($row, $barcodeFields);
if ($barcode === '') {
continue;
}
$barcodeKey = $this->normalizeBarcode($barcode);
$purchaseNo = $this->normalizePurchaseNo($row[$purchaseField] ?? '');
$date = $this->normalizeDate($row[$dateField] ?? '');
$unitPrice = (float) ($row['unitPrice'] ?? 0);
$key = $this->normalizeBarcode($barcode) . '|' . number_format($unitPrice, 4, '.', '');
$key = $this->matchKey($purchaseNo, $date, $barcodeKey);
$grouped[$key] ??= ['barcode' => $barcode, 'unitPrice' => $unitPrice, 'qty' => 0.0, 'amount' => 0.0];
$grouped[$key] ??= [
'purchaseNo' => $purchaseNo,
'date' => $date,
'barcode' => $barcode,
'barcodeKey' => $barcodeKey,
'supplierBarcode' => in_array('barcode', $barcodeFields, true) ? $barcode : '',
'unitPrice' => $unitPrice,
'unitPrices' => [],
'qty' => 0.0,
'amount' => 0.0,
'matchedByFallback' => false,
'fallbackAmbiguous' => false,
];
if ($unitPrice > 0) {
$grouped[$key]['unitPrices'][number_format($unitPrice, 4, '.', '')] = true;
}
$grouped[$key]['qty'] += (float) ($row['qty'] ?? 0);
$grouped[$key]['amount'] += (float) ($row['amount'] ?? 0);
}
foreach ($grouped as &$group) {
$group = $this->finalizeGroup($group);
}
unset($group);
return $grouped;
}
/** @param array<string,mixed> $row @param list<string> $fields */
private function barcodeValue(array $row, array $fields): string
{
foreach ($fields as $field) {
$value = trim((string) ($row[$field] ?? ''));
if ($value !== '') {
return $value;
}
}
return '';
}
/**
* @param array<string,array<string,mixed>> $groups
* @param array<string,list<string>> $fallbackIndex
* @return array<string,array<string,mixed>>
*/
private function resolveMissingPurchaseNumbers(array $groups, array $fallbackIndex): array
{
$resolved = [];
foreach ($groups as $key => $group) {
$targetKey = $key;
if ($group['purchaseNo'] === '') {
$candidates = $fallbackIndex[$group['date'] . "\0" . $group['barcodeKey']] ?? [];
if (count($candidates) === 1) {
$targetKey = $candidates[0];
$group['matchedByFallback'] = true;
} elseif (count($candidates) > 1) {
$group['fallbackAmbiguous'] = true;
}
}
if (isset($resolved[$targetKey])) {
$resolved[$targetKey] = $this->mergeGroup($resolved[$targetKey], $group);
} else {
$resolved[$targetKey] = $group;
}
}
return $resolved;
}
/** @param array<string,mixed> $left @param array<string,mixed> $right */
private function mergeGroup(array $left, array $right): array
{
$left['qty'] += (float) $right['qty'];
$left['amount'] += (float) $right['amount'];
$left['unitPrices'] += (array) $right['unitPrices'];
$left['matchedByFallback'] = (bool) $left['matchedByFallback'] || (bool) $right['matchedByFallback'];
$left['fallbackAmbiguous'] = (bool) $left['fallbackAmbiguous'] || (bool) $right['fallbackAmbiguous'];
if ($left['supplierBarcode'] === '' && $right['supplierBarcode'] !== '') {
$left['supplierBarcode'] = $right['supplierBarcode'];
}
return $this->finalizeGroup($left);
}
/** @param array<string,mixed> $group @return array<string,mixed> */
private function finalizeGroup(array $group): array
{
$prices = array_keys((array) ($group['unitPrices'] ?? []));
if (count($prices) === 1) {
$group['unitPrice'] = (float) $prices[0];
} elseif ((float) $group['qty'] != 0.0) {
$group['unitPrice'] = round((float) $group['amount'] / (float) $group['qty'], 4);
}
return $group;
}
private function matchKey(string $purchaseNo, string $date, string $barcode): string
{
return $purchaseNo . "\0" . $date . "\0" . $barcode;
}
private function normalizePurchaseNo(mixed $value): string
{
$purchaseNo = ltrim(trim((string) $value), "'");
return preg_match('/^(\d+)\.0+$/', $purchaseNo, $matches) === 1 ? $matches[1] : $purchaseNo;
}
private function normalizeDate(mixed $value): string
{
$raw = trim((string) $value);
if (preg_match('/^(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})/', $raw, $matches) === 1) {
$year = (int) $matches[1];
$month = (int) $matches[2];
$day = (int) $matches[3];
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
return $raw;
}
private function describeDifference(float $qtyDifference, float $amountDifference): string
{
$parts = [];
if (abs($qtyDifference) >= 0.005) {
$parts[] = '厂家数量' . ($qtyDifference > 0 ? '多 ' : '少 ') . $this->number(abs($qtyDifference));
}
if (abs($amountDifference) >= 0.005) {
$parts[] = '厂家金额' . ($amountDifference > 0 ? '多 ¥' : '少 ¥') . number_format(abs($amountDifference), 2);
}
return $parts === [] ? '数量、金额一致' : implode(',', $parts);
}
private function number(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
/** 去掉条码末尾的「厂家编号 + 版本」后缀,双方口径统一后再匹配。 */
private function normalizeBarcode(string $barcode): string
{
$normalized = preg_replace(self::SUPPLIER_SUFFIX, '', $barcode);
return $normalized === null || $normalized === '' ? $barcode : $normalized;
return strtoupper($normalized === null || $normalized === '' ? $barcode : $normalized);
}
}

View File

@ -12,11 +12,11 @@ class UpstreamConnection
/** @var array<string,PDO> */
private static array $native = [];
public static function get(string $source): PDO
public static function get(string $source, ?int $queryTimeout = null): PDO
{
$registry = app(UpstreamSourceRegistry::class);
if ($registry->driver($source) === 'sqlsrv') {
return UpstreamOdbcConnection::get($source);
return UpstreamOdbcConnection::get($source, $queryTimeout);
}
if (! $registry->enabled($source)) {
throw new ServiceUnavailableHttpException(30, '该数据源尚未启用。');

View File

@ -20,12 +20,21 @@ class UpstreamOdbcConnection
/** @var array<string,PDO> */
private static array $instances = [];
public static function get(string $source): PDO
/** @var array<string,PDO> */
private static array $timeoutInstances = [];
public static function get(string $source, ?int $queryTimeout = null): PDO
{
$definition = app(UpstreamSourceRegistry::class)->definition($source);
$key = $definition['key'];
return self::$instances[$key] ??= self::create($key);
if ($queryTimeout === null) {
return self::$instances[$key] ??= self::create($key);
}
$cacheKey = $key . '@' . $queryTimeout;
return self::$timeoutInstances[$cacheKey] ??= self::connect($key, true, $queryTimeout);
}
public static function create(string $source): PDO
@ -125,7 +134,7 @@ PHP;
}
}
private static function connect(string $source, bool $requireEnabled): PDO
private static function connect(string $source, bool $requireEnabled, ?int $queryTimeout = null): PDO
{
$sources = app(UpstreamSourceRegistry::class);
$definition = $sources->definition($source);
@ -134,8 +143,13 @@ PHP;
throw new ServiceUnavailableHttpException(60, '该上游数据同步尚未启用,请联系管理员。');
}
$connection = (array) $sources->value($source, 'connection', []);
if ($queryTimeout !== null) {
$connection['query_timeout'] = $queryTimeout;
}
return self::open(
(array) $sources->value($source, 'connection', []),
$connection,
$definition['key'],
$definition['label'],
);
@ -219,12 +233,18 @@ PHP;
{
if ($source === null) {
self::$instances = [];
self::$timeoutInstances = [];
return;
}
$key = app(UpstreamSourceRegistry::class)->definition($source)['key'];
unset(self::$instances[$key]);
foreach (array_keys(self::$timeoutInstances) as $cacheKey) {
if (str_starts_with($cacheKey, $key . '@')) {
unset(self::$timeoutInstances[$cacheKey]);
}
}
}
private static function quoteOdbcValue(string $value): string

View File

@ -13,8 +13,10 @@ use PDO;
*/
class UpstreamReadOnlyQuery
{
public function __construct(private readonly string $source)
{
public function __construct(
private readonly string $source,
private readonly ?int $queryTimeout = null,
) {
}
public function sourceKey(): string
@ -27,7 +29,7 @@ class UpstreamReadOnlyQuery
{
self::assertReadOnlySql($sql);
$statement = UpstreamConnection::get($this->source)->prepare($sql);
$statement = UpstreamConnection::get($this->source, $this->queryTimeout)->prepare($sql);
$statement->execute($bindings);
return $statement->fetchAll(PDO::FETCH_ASSOC);
@ -45,7 +47,7 @@ class UpstreamReadOnlyQuery
{
self::assertReadOnlySql($sql);
$statement = UpstreamConnection::get($this->source)->prepare($sql);
$statement = UpstreamConnection::get($this->source, $this->queryTimeout)->prepare($sql);
$statement->execute($bindings);
return $statement->fetchColumn();

View File

@ -7,6 +7,9 @@ class UpstreamReadOnlyQueryFactory
/** @var array<string,UpstreamReadOnlyQuery> */
private array $instances = [];
/** @var array<string,UpstreamReadOnlyQuery> */
private array $timedInstances = [];
public function __construct(private readonly UpstreamSourceRegistry $sources)
{
}
@ -17,4 +20,13 @@ class UpstreamReadOnlyQueryFactory
return $this->instances[$key] ??= new UpstreamReadOnlyQuery($key);
}
public function forSourceWithTimeout(string $source, int $timeoutSeconds): UpstreamReadOnlyQuery
{
$key = $this->sources->definition($source)['key'];
$timeoutSeconds = max(1, min($timeoutSeconds, 120));
$cacheKey = $key . '@' . $timeoutSeconds;
return $this->timedInstances[$cacheKey] ??= new UpstreamReadOnlyQuery($key, $timeoutSeconds);
}
}

View File

@ -2,6 +2,10 @@
namespace Modules\EcommerceFinance\View;
use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerAccountShouldKeep;
use App\Utilities\FinanceLedgerAccountType;
use App\Utilities\FinanceLedgerAccess;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
@ -23,6 +27,8 @@ class FinanceLedgerPageData
'sources' => ['title' => '数据来源', 'icon' => 'database'],
];
private const ACCOUNT_FILTER_EMPTY = '__empty__';
public function page(?string $tab = null): array
{
$tab = (string) (request('tab') ?: $tab);
@ -51,35 +57,152 @@ class FinanceLedgerPageData
'ledgerSourceSummary' => $this->sourceSummary(),
'ledgerMeta' => $this->meta(),
'ledgerAccountOptions' => $this->accountOptions(),
'ledgerCanEditMapping' => \App\Utilities\FinanceLedgerAccess::canEditMapping(user()),
'ledgerAccountFilterOptions' => $this->accountFilterOptions(),
'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS,
'ledgerAccountPlanOptions' => $this->accountPlanOptions(),
'ledgerAccountKeepOptions' => FinanceLedgerAccountShouldKeep::OPTIONS,
'ledgerCanEditMapping' => FinanceLedgerAccess::canEditMapping(user()),
'ledgerCanEditAccountStatus' => FinanceLedgerAccess::canEditAccountStatus(user()),
'ledgerCanEditAccountType' => FinanceLedgerAccess::canEditAccountType(user()),
];
}
private function accounts(): array
{
$query = $this->db()->table('fin_accounts');
$query = $this->db()->table('fin_accounts as a');
$keyword = trim((string) request('accounts_q', ''));
if ($keyword !== '') {
$query->where(function (Builder $builder) use ($keyword): void {
foreach (['company_name', 'account_short', 'account_no', 'owner'] as $column) {
foreach (['a.company_name', 'a.account_short', 'a.account_no', 'a.owner'] as $column) {
$builder->orWhere($column, 'like', '%' . $keyword . '%');
}
});
}
$accountShort = trim((string) request('accounts_short', ''));
if ($accountShort !== '') {
$query->where('a.account_short', 'like', '%' . $accountShort . '%');
}
$status = trim((string) request('accounts_status', ''));
if ($status !== '' && array_key_exists($status, FinanceLedgerAccountStatus::OPTIONS)) {
if ($status === FinanceLedgerAccountStatus::NORMAL) {
$nonNormalStatuses = array_values(array_diff(
array_keys(FinanceLedgerAccountStatus::OPTIONS),
[FinanceLedgerAccountStatus::NORMAL],
));
$query->whereNotIn(
'a.account_short',
$this->db()->table('fin_account_statuses')
->select('account_short')
->whereIn('status', $nonNormalStatuses),
);
} else {
$query->whereIn(
'a.account_short',
$this->db()->table('fin_account_statuses')
->select('account_short')
->where('status', $status),
);
}
}
$region = trim((string) request('accounts_region', ''));
if ($region !== '') {
if ($region === self::ACCOUNT_FILTER_EMPTY) {
$query->where(function (Builder $builder): void {
$builder->whereNull('a.region')->orWhere('a.region', '');
});
} else {
$query->where('a.region', $region);
}
}
$accountType = trim((string) request('accounts_type', ''));
if ($accountType !== '') {
$query->where(function (Builder $effectiveType) use ($accountType): void {
$effectiveType->where(function (Builder $baseType) use ($accountType): void {
$baseType->whereNotIn(
'a.account_short',
$this->db()->table('fin_account_type_overrides')->select('account_short'),
);
if ($accountType === self::ACCOUNT_FILTER_EMPTY) {
$baseType->where(function (Builder $blank): void {
$blank->whereNull('a.account_type')->orWhere('a.account_type', '');
});
} else {
$baseType->where('a.account_type', $accountType);
}
});
$overrideType = $this->db()->table('fin_account_type_overrides')->select('account_short');
if ($accountType === self::ACCOUNT_FILTER_EMPTY) {
$overrideType->where(function (Builder $blank): void {
$blank->whereNull('account_type')->orWhere('account_type', '');
});
} else {
$overrideType->where('account_type', $accountType);
}
$effectiveType->orWhereIn('a.account_short', $overrideType);
});
}
$balanceRange = trim((string) request('accounts_balance', ''));
if ($balanceRange === '0-5') {
$query->where('a.current_balance', '>=', 0)->where('a.current_balance', '<', 50000);
} elseif ($balanceRange === '5-20') {
$query->where('a.current_balance', '>=', 50000)->where('a.current_balance', '<', 200000);
} elseif ($balanceRange === '20-plus') {
$query->where('a.current_balance', '>=', 200000);
}
$headerColumns = $this->accountHeaderColumns();
$this->applyLedgerHeaderFilter($query, 'accounts', $headerColumns);
$summaryQuery = clone $query;
$summary = [
'count' => (clone $query)->count(),
'balance' => round((float) (clone $summaryQuery)->sum('current_balance'), 2),
'blocked' => round((float) (clone $summaryQuery)->sum('blocked_amount'), 2),
'unretained' => FinanceLedgerAccountShouldKeep::applyUnretainedCondition(
clone $query,
'a.account_should_keep',
'a.company_plan',
)->count(),
'current_balance' => round((float) (clone $summaryQuery)->sum('a.current_balance'), 2),
'balance' => round((float) (clone $summaryQuery)->sum('a.daily_available'), 2),
'blocked' => round((float) (clone $summaryQuery)->sum('a.blocked_amount'), 2),
];
$sorted = $this->applyLedgerHeaderSort($query, 'accounts', $headerColumns);
if (! $sorted) {
$query->orderBy('a.company_name')->orderBy('a.account_short');
}
$rows = $query
->orderBy('company_name')
->orderBy('account_short')
->paginate(100, ['*'], 'accounts_page')
->withQueryString();
$accountShorts = $rows->pluck('account_short')->filter()->unique()->values()->all();
$statuses = $accountShorts === []
? collect()
: $this->db()->table('fin_account_statuses')
->whereIn('account_short', $accountShorts)
->pluck('status', 'account_short');
$typeOverrides = $accountShorts === []
? collect()
: $this->db()->table('fin_account_type_overrides')
->whereIn('account_short', $accountShorts)
->pluck('account_type', 'account_short');
$rows->getCollection()->transform(static function ($row) use ($statuses, $typeOverrides) {
$row->account_status = FinanceLedgerAccountStatus::normalize($statuses[$row->account_short] ?? null);
if ($typeOverrides->has($row->account_short)) {
$row->account_type = (string) ($typeOverrides->get($row->account_short) ?? '');
}
$row->account_should_keep = FinanceLedgerAccountShouldKeep::display(
$row->account_should_keep ?? null,
$row->company_plan ?? null,
);
return $row;
});
return ['rows' => $rows, 'summary' => $summary];
}
@ -98,13 +221,20 @@ class FinanceLedgerPageData
});
}
$headerColumns = $this->shopHeaderColumns();
$this->applyLedgerHeaderFilter($query, 'shops', $headerColumns);
$summary = [
'count' => (clone $query)->count(),
'balance' => round((float) (clone $query)->sum('shop_balance'), 2),
];
$sorted = $this->applyLedgerHeaderSort($query, 'shops', $headerColumns);
if (! $sorted) {
$query->orderBy('company_name')->orderBy('shop_name');
}
return [
'rows' => $query->orderBy('company_name')->orderBy('shop_name')
'rows' => $query
->paginate(100, ['*'], 'shops_page')->withQueryString(),
'summary' => $summary,
];
@ -120,6 +250,8 @@ class FinanceLedgerPageData
}
}
$headerColumns = $this->flowHeaderColumns();
$this->applyLedgerHeaderFilter($query, 'flows', $headerColumns);
$summary = [
'count' => (clone $query)->count(),
'closing' => round((float) (clone $query)->sum('closing_balance'), 2),
@ -127,8 +259,13 @@ class FinanceLedgerPageData
'outflow' => round((float) (clone $query)->sum('outflow'), 2),
];
$sorted = $this->applyLedgerHeaderSort($query, 'flows', $headerColumns);
if (! $sorted) {
$query->orderBy('flow_date')->orderBy('account_short');
}
return [
'rows' => $query->orderBy('flow_date')->orderBy('account_short')
'rows' => $query
->paginate(100, ['*'], 'flows_page')->withQueryString(),
'summary' => $summary,
];
@ -149,7 +286,8 @@ class FinanceLedgerPageData
}
$account = trim((string) request('txns_account', ''));
if ($account !== '') {
$query->whereRaw("COALESCE(NULLIF(m.account_short,''), NULLIF(t.account_short,''), '未匹配') LIKE ?", ['%' . $account . '%']);
$effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort'];
$query->whereRaw($effectiveAccount . ' LIKE ?', ['%' . $account . '%']);
}
$from = trim((string) request('txns_from', ''));
if ($from !== '') {
@ -164,14 +302,21 @@ class FinanceLedgerPageData
$query->where('t.source', $source);
}
$headerColumns = $this->transactionHeaderColumns();
$this->applyLedgerHeaderFilter($query, 'transactions', $headerColumns);
$summary = [
'count' => (clone $query)->count(),
'income' => round((float) (clone $query)->sum('t.income'), 2),
'expense' => round((float) (clone $query)->sum('t.expense'), 2),
];
$sorted = $this->applyLedgerHeaderSort($query, 'transactions', $headerColumns);
if (! $sorted) {
$query->orderByDesc('t.txn_date')->orderByDesc('t.id');
}
return [
'rows' => $query->orderByDesc('t.txn_date')->orderByDesc('t.id')
'rows' => $query
->paginate(100, ['*'], 'txns_page')->withQueryString(),
'summary' => $summary,
];
@ -179,11 +324,29 @@ class FinanceLedgerPageData
private function transactionQuery(): Builder
{
$effectiveAccount = $this->transactionHeaderColumns()['effective_account_short']['sort'];
return $this->db()->table('fin_transactions as t')
->leftJoin('fin_account_map as m', function ($join): void {
$join->on('m.source', '=', 't.source')->on('m.source_value', '=', 't.source_value');
})
->selectRaw("t.*, COALESCE(NULLIF(m.account_short,''), NULLIF(t.account_short,''), '未匹配') AS effective_account_short");
->select([
't.id',
't.txn_date',
't.account_short',
't.detail',
't.income',
't.expense',
't.approval_no',
't.source',
't.source_value',
't.process_type',
't.row_key',
't.synced_at',
't.created_at',
't.updated_at',
])
->selectRaw($effectiveAccount . ' AS effective_account_short');
}
private function mapping(): array
@ -206,11 +369,17 @@ class FinanceLedgerPageData
});
}
$headerColumns = $this->mappingHeaderColumns();
$this->applyLedgerHeaderFilter($query, 'mapping', $headerColumns);
$total = (clone $query)->count();
$mapped = (clone $query)->whereNotNull('account_short')->where('account_short', '<>', '')->count();
$sorted = $this->applyLedgerHeaderSort($query, 'mapping', $headerColumns);
if (! $sorted) {
$query->orderByDesc('txn_count')->orderBy('source_value');
}
return [
'rows' => $query->orderByDesc('txn_count')->orderBy('source_value')
'rows' => $query
->paginate(100, ['*'], 'mapping_page')->withQueryString(),
'total' => $total,
'mapped' => $mapped,
@ -222,14 +391,21 @@ class FinanceLedgerPageData
private function sourceSummary(): array
{
return $this->db()->table('fin_transactions')
$rows = $this->db()->table('fin_transactions')
->selectRaw('source, process_type, COUNT(*) AS rows_count, SUM(income) AS income, SUM(expense) AS expense, MIN(txn_date) AS first_date, MAX(txn_date) AS last_date')
->groupBy('source', 'process_type')
->orderBy('source')
->orderByDesc('rows_count')
->get()
->map(static fn ($row): array => (array) $row)
->map(static function ($row): array {
$data = (array) $row;
$data['date_range'] = trim((string) ($data['first_date'] ?? '') . ' ~ ' . (string) ($data['last_date'] ?? ''));
return $data;
})
->all();
return $this->filterAndSortHeaderRows($rows, 'sources', $this->sourceHeaderColumns());
}
private function meta(): array
@ -256,8 +432,255 @@ class FinanceLedgerPageData
->select('account_short')->distinct()->orderBy('account_short')->pluck('account_short')->all();
}
private function accountFilterOptions(): array
{
$baseTypes = $this->db()->table('fin_accounts')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->orderBy('account_type')->pluck('account_type')->all();
$overrideTypes = $this->db()->table('fin_account_type_overrides')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->orderBy('account_type')->pluck('account_type')->all();
$types = FinanceLedgerAccountType::options($baseTypes, $overrideTypes);
return [
'regions' => $this->db()->table('fin_accounts')
->whereNotNull('region')->where('region', '<>', '')
->distinct()->orderBy('region')->pluck('region')->all(),
'types' => $types,
];
}
private function accountPlanOptions(): array
{
return $this->db()->table('fin_accounts')
->whereNotNull('company_plan')
->where('company_plan', '<>', '')
->distinct()
->orderBy('company_plan')
->pluck('company_plan')
->map(static fn (mixed $value): string => trim((string) $value))
->filter()
->unique()
->values()
->all();
}
private function applyLedgerHeaderFilter(Builder $query, string $tab, array $columns): void
{
$column = trim((string) request($tab . '_filter_column', ''));
$value = trim((string) request($tab . '_filter_value', ''));
if ($value === '' || ! isset($columns[$column])) {
return;
}
$definition = $columns[$column];
$expression = $definition['filter'] ?? $definition['sort'] ?? null;
if (! is_string($expression) || $expression === '') {
return;
}
$type = $definition['type'] ?? 'text';
if ($type === 'number') {
if (is_numeric($value)) {
$query->whereRaw($expression . ' = ?', [$value]);
}
return;
}
if (in_array($type, ['enum', 'date'], true)) {
$query->whereRaw($expression . ' = ?', [$value]);
return;
}
$query->whereRaw($expression . ' LIKE ?', ['%' . $value . '%']);
}
private function applyLedgerHeaderSort(Builder $query, string $tab, array $columns): bool
{
$column = trim((string) request($tab . '_sort', ''));
if (! isset($columns[$column])) {
return false;
}
$expression = $columns[$column]['sort'] ?? null;
if (! is_string($expression) || $expression === '') {
return false;
}
$direction = strtolower((string) request($tab . '_direction', 'asc')) === 'desc' ? 'desc' : 'asc';
$query->orderByRaw($expression . ' ' . $direction);
return true;
}
private function filterAndSortHeaderRows(array $rows, string $tab, array $columns): array
{
$filterColumn = trim((string) request($tab . '_filter_column', ''));
$filterValue = trim((string) request($tab . '_filter_value', ''));
if ($filterValue !== '' && isset($columns[$filterColumn])) {
$valueColumn = $columns[$filterColumn]['value'] ?? $filterColumn;
$type = $columns[$filterColumn]['type'] ?? 'text';
$rows = array_values(array_filter(
$rows,
static function (array $row) use ($valueColumn, $filterValue, $type): bool {
$value = trim((string) ($row[$valueColumn] ?? ''));
if ($type === 'number') {
return is_numeric($filterValue) && is_numeric($value) && (float) $value === (float) $filterValue;
}
if (in_array($type, ['enum', 'date'], true)) {
return $value === $filterValue;
}
return mb_stripos($value, $filterValue) !== false;
},
));
}
$sortColumn = trim((string) request($tab . '_sort', ''));
if (isset($columns[$sortColumn])) {
$valueColumn = $columns[$sortColumn]['value'] ?? $sortColumn;
$direction = strtolower((string) request($tab . '_direction', 'asc')) === 'desc' ? -1 : 1;
$type = $columns[$sortColumn]['type'] ?? 'text';
usort($rows, static function (array $left, array $right) use ($valueColumn, $direction, $type): int {
$leftValue = $left[$valueColumn] ?? null;
$rightValue = $right[$valueColumn] ?? null;
if ($type === 'number') {
return ((float) $leftValue <=> (float) $rightValue) * $direction;
}
return strnatcasecmp((string) $leftValue, (string) $rightValue) * $direction;
});
}
return $rows;
}
private function accountHeaderColumns(): array
{
$a = $this->queryAlias('a');
$statusTable = $this->tableName('fin_account_statuses');
$typeTable = $this->tableName('fin_account_type_overrides');
$statusExpression = "COALESCE((SELECT NULLIF(s.status, '') FROM {$statusTable} s WHERE s.account_short = {$a}.account_short LIMIT 1), 'normal')";
$typeExpression = "COALESCE((SELECT NULLIF(o.account_type, '') FROM {$typeTable} o WHERE o.account_short = {$a}.account_short LIMIT 1), NULLIF({$a}.account_type, ''), '')";
$keepExpression = "COALESCE(NULLIF({$a}.account_should_keep, ''), CASE WHEN COALESCE({$a}.company_plan, '') = '' THEN '' WHEN {$a}.company_plan LIKE '%保留%' THEN '是' ELSE '否' END)";
return [
'company_name' => ['sort' => $a . '.company_name', 'type' => 'text'],
'account_short' => ['sort' => $a . '.account_short', 'type' => 'text'],
'account_type' => ['sort' => $typeExpression, 'type' => 'enum'],
'account_status' => ['sort' => $statusExpression, 'type' => 'enum'],
'region' => ['sort' => $a . '.region', 'type' => 'text'],
'account_no' => ['sort' => $a . '.account_no', 'type' => 'text'],
'bank_name' => ['sort' => $a . '.bank_name', 'type' => 'text'],
'bank_city_branch' => ['sort' => $a . '.bank_city_branch', 'type' => 'text'],
'company_plan' => ['sort' => $a . '.company_plan', 'type' => 'enum'],
'account_should_keep' => ['sort' => $keepExpression, 'type' => 'enum'],
'account_last4' => ['sort' => $a . '.account_last4', 'type' => 'text'],
'single_limit' => ['sort' => "CAST({$a}.single_limit AS DECIMAL(18, 2))", 'filter' => $a . '.single_limit', 'type' => 'number'],
'daily_limit' => ['sort' => "CAST({$a}.daily_limit AS DECIMAL(18, 2))", 'filter' => $a . '.daily_limit', 'type' => 'number'],
'monthly_limit' => ['sort' => "CAST({$a}.monthly_limit AS DECIMAL(18, 2))", 'filter' => $a . '.monthly_limit', 'type' => 'number'],
'yearly_limit' => ['sort' => "CAST({$a}.yearly_limit AS DECIMAL(18, 2))", 'filter' => $a . '.yearly_limit', 'type' => 'number'],
'current_balance' => ['sort' => $a . '.current_balance', 'type' => 'number'],
'daily_available' => ['sort' => $a . '.daily_available', 'type' => 'number'],
'blocked_amount' => ['sort' => $a . '.blocked_amount', 'type' => 'number'],
'daily_withdraw' => ['sort' => $a . '.daily_available', 'type' => 'number'],
'clearing_days' => ['sort' => $a . '.clearing_days', 'type' => 'number'],
'raise_target' => ['sort' => "CAST({$a}.raise_target AS DECIMAL(18, 2))", 'filter' => $a . '.raise_target', 'type' => 'number'],
'raise_status' => ['sort' => $a . '.raise_status', 'type' => 'text'],
'raise_actions' => ['sort' => $a . '.raise_actions', 'type' => 'text'],
'done_actions' => ['sort' => $a . '.done_actions', 'type' => 'text'],
'next_action' => ['sort' => $a . '.next_action', 'type' => 'text'],
'remark' => ['sort' => $a . '.remark', 'type' => 'text'],
'owner' => ['sort' => $a . '.owner', 'type' => 'text'],
];
}
private function shopHeaderColumns(): array
{
return [
'shop_name' => ['sort' => 'shop_name', 'type' => 'text'],
'platform' => ['sort' => 'platform', 'type' => 'text'],
'company_name' => ['sort' => 'company_name', 'type' => 'text'],
'account_short' => ['sort' => 'account_short', 'type' => 'text'],
'status' => ['sort' => 'status', 'type' => 'text'],
'payout_account' => ['sort' => 'payout_account', 'type' => 'text'],
'shop_balance' => ['sort' => 'shop_balance', 'type' => 'number'],
'remark' => ['sort' => 'remark', 'type' => 'text'],
];
}
private function flowHeaderColumns(): array
{
return [
'flow_date' => ['sort' => 'flow_date', 'type' => 'date'],
'company_name' => ['sort' => 'company_name', 'type' => 'text'],
'account_short' => ['sort' => 'account_short', 'type' => 'text'],
'opening_balance' => ['sort' => 'opening_balance', 'type' => 'number'],
'inflow' => ['sort' => 'inflow', 'type' => 'number'],
'outflow' => ['sort' => 'outflow', 'type' => 'number'],
'closing_balance' => ['sort' => 'closing_balance', 'type' => 'number'],
'remark' => ['sort' => 'remark', 'type' => 'text'],
];
}
private function transactionHeaderColumns(): array
{
$t = $this->queryAlias('t');
$m = $this->queryAlias('m');
$effectiveAccount = "COALESCE(NULLIF({$m}.account_short, ''), NULLIF({$t}.account_short, ''), '未匹配')";
return [
'txn_date' => ['sort' => $t . '.txn_date', 'type' => 'date'],
'effective_account_short' => ['sort' => $effectiveAccount, 'type' => 'text'],
'detail' => ['sort' => $t . '.detail', 'type' => 'text'],
'source' => ['sort' => $t . '.source', 'type' => 'enum'],
'income' => ['sort' => $t . '.income', 'type' => 'number'],
'expense' => ['sort' => $t . '.expense', 'type' => 'number'],
'approval_no' => ['sort' => $t . '.approval_no', 'type' => 'text'],
];
}
private function mappingHeaderColumns(): array
{
$mappingStatus = "CASE WHEN COALESCE(account_short, '') = '' THEN 'unmapped' ELSE 'mapped' END";
return [
'source' => ['sort' => 'source', 'type' => 'enum'],
'source_value' => ['sort' => 'source_value', 'type' => 'text'],
'txn_count' => ['sort' => 'txn_count', 'type' => 'number'],
'income_sum' => ['sort' => 'income_sum', 'type' => 'number'],
'expense_sum' => ['sort' => 'expense_sum', 'type' => 'number'],
'account_short' => ['sort' => 'account_short', 'type' => 'text'],
'mapping_status' => ['sort' => $mappingStatus, 'type' => 'enum'],
];
}
private function sourceHeaderColumns(): array
{
return [
'source' => ['value' => 'source', 'type' => 'enum'],
'process_type' => ['value' => 'process_type', 'type' => 'text'],
'rows_count' => ['value' => 'rows_count', 'type' => 'number'],
'income' => ['value' => 'income', 'type' => 'number'],
'expense' => ['value' => 'expense', 'type' => 'number'],
'date_range' => ['value' => 'date_range', 'type' => 'text'],
];
}
private function db()
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
private function tableName(string $table): string
{
return $this->db()->getTablePrefix() . $table;
}
private function queryAlias(string $alias): string
{
return $this->db()->getTablePrefix() . $alias;
}
}

View File

@ -168,6 +168,35 @@ class OrderDataPageData
public function afterSalesRows(array $filters, int $page, int $limit): array
{
$limit = min(max($limit, 1), 100);
// 聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」原表:
// 已按商品编码(组合装拆成组件)给出行和成本,镜像就绪后优先使用。
$goodsProfit = app(\Modules\EcommerceFinance\Repositories\GoodsProfitAfsRepository::class);
if ($goodsProfit->covers($filters)) {
$result = $goodsProfit->paginate($filters, $page, $limit);
return [
'rows' => $result['rows'],
'has_more' => $result['has_more'],
'status' => ['X-Caiwu-Data-Status' => 'goods_profit'],
];
}
// 聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」导入的明细:
// 已经是按商品编码(组合装拆成组件)的行,覆盖到的店铺/日期范围优先使用。
$imported = app(\Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository::class);
if ($imported->covers($filters)) {
$result = $imported->paginate($filters, $page, $limit);
return [
'rows' => $result['rows'],
'has_more' => $result['has_more'],
'status' => ['X-Caiwu-Data-Status' => 'imported'],
];
}
$mirror = $this->mirrorOrders ?? app(OrderDataMirrorRepository::class);
if ($mirror->ready()) {
$result = $mirror->afterSales($filters, $page, $limit);

View File

@ -4,14 +4,35 @@ namespace Modules\EcommerceFinance\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\SupplierFreeShippingStore;
use Throwable;
class SupplierManagementPageData
{
public const TABS = ['contracts' => '合同信息', 'contacts' => '联系信息', 'payments' => '打款信息', 'logs' => '操作日志'];
public function __construct(private readonly SupplierManagementMirrorRepository $repository)
public const FREE_SHIPPING_COLUMN = SupplierFreeShippingStore::FIELD;
public const FREE_SHIPPING_LABEL = '是否包邮厂家';
public function __construct(
private readonly SupplierManagementMirrorRepository $repository,
private readonly SupplierFreeShippingStore $freeShipping,
) {
}
/** 是否包邮厂家紧跟在供应商名称后展示。 */
public static function columns(): array
{
$columns = [];
foreach (SupplierManagementMirrorRepository::COLUMNS as $key => $label) {
$columns[$key] = $label;
if ($key === 'scmname') {
$columns[self::FREE_SHIPPING_COLUMN] = self::FREE_SHIPPING_LABEL;
}
}
return $columns;
}
public function filters(array $input): array
@ -39,6 +60,10 @@ class SupplierManagementPageData
$ready = $this->repository->ready();
if ($ready) {
$result = $this->repository->paginate($filters, $page, $limit);
$result['rows'] = $this->freeShipping->decorateRows(
$result['rows'],
(int) (company_id() ?: 1),
);
if ((int) request('supplier_id', 0) > 0) {
// 选择必须仍在当前列表中;筛选和翻页后清除旧选择。
$selected = collect($result['rows'])->firstWhere('Id', (int) request('supplier_id'));
@ -61,7 +86,7 @@ class SupplierManagementPageData
return [
'view' => 'business.pages.supplier-payables.supplier-management',
'supplierFilters' => $filters, 'supplierState' => $state, 'supplierReady' => $ready,
'supplierError' => $error, 'supplierColumns' => SupplierManagementMirrorRepository::COLUMNS,
'supplierError' => $error, 'supplierColumns' => self::columns(),
'suppliers' => new LengthAwarePaginator($result['rows'], $result['total'], $limit, $page,
['path' => request()->url(), 'query' => request()->except('page', 'supplier_id', 'detail_page')]),
'selectedSupplier' => $selected, 'supplierDetailTab' => $tab,

View File

@ -3,6 +3,7 @@
namespace Modules\EcommerceFinance\View;
use App\Http\Controllers\Business\PayablesFinalActions;
use App\Services\Auth\SupplierPortalAccess;
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\Services\SupplierReturnAccounting;
@ -12,7 +13,7 @@ use Modules\EcommerceFinance\Services\SupplierStatementStore;
/**
* 「供应商对账」页面数据:
* - 财务端数据:按供应商汇总 + 行级差异(条码 + 单价 匹配,四类结果带结算口径)
* - 财务端数据:按供应商汇总 + 行级差异(采购单号 + 日期 + 条码匹配,四类结果带结算口径)
* - 厂家端数据:厂家上传的对账单(实时)与只读的退货明细
*/
class SupplierReconciliationPageData
@ -23,6 +24,9 @@ class SupplierReconciliationPageData
/** 退货明细行较高:每页 10 行,并在页面内固定表头滚动查看。 */
private const RETURN_PER_PAGE = 10;
/** 财务端比对明细:每页 15 条。 */
private const COMPARISON_PER_PAGE = 15;
public function __construct(
private readonly SupplierPayableOverrideStore $store,
private readonly SupplierStatementStore $statements,
@ -30,6 +34,7 @@ class SupplierReconciliationPageData
private readonly SupplierReturnConfirmation $returnConfirmations,
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
private readonly SupplierReturnAccounting $returnAccounting,
private readonly SupplierPortalAccess $supplierPortalAccess,
) {
}
@ -109,17 +114,31 @@ class SupplierReconciliationPageData
}
// 选中厂家时页面切换成「比对明细」视图(列表不再渲染,避免 100+ 家时往下翻)。
$selected = trim((string) request()->query('supplier', ''));
$supplierQuery = trim((string) request()->query('supplier', ''));
$selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : '';
$supplierNotFound = $supplierQuery !== '' && $selected === '';
$detail = null;
if ($selected !== '' && in_array($selected, $supplierNames, true)) {
if ($selected !== '') {
$detail = $this->detail($mine, $selected, $companyId);
} else {
$selected = '';
$totalRows = count($detail['rows']);
$pages = max((int) ceil($totalRows / self::COMPARISON_PER_PAGE), 1);
$currentPage = min(max((int) request()->query('compare_page', 1), 1), $pages);
$detail['total_rows'] = $totalRows;
$detail['page'] = $currentPage;
$detail['pages'] = $pages;
$detail['per_page'] = self::COMPARISON_PER_PAGE;
$detail['rows'] = array_slice(
$detail['rows'],
($currentPage - 1) * self::COMPARISON_PER_PAGE,
self::COMPARISON_PER_PAGE,
);
}
return [
'view' => 'business.pages.supplier-reconciliation.finance-data',
'supplierNames' => $supplierNames,
'supplierQuery' => $supplierQuery,
'supplierNotFound' => $supplierNotFound,
'uploads' => $uploads,
'months' => $months,
'month' => $month,
@ -146,38 +165,48 @@ class SupplierReconciliationPageData
public function supplierData(): array
{
$companyId = $this->companyId();
$mine = $this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:payment-reconciliation',
$companyId,
);
$portalUser = request()->user();
$supplierLocked = $portalUser && SupplierPortalAccess::isSupplierUser($portalUser);
$supplierNames = [];
$supplierNotFound = false;
$suppliers = [];
foreach ($mine as $row) {
$name = trim((string) ($row['supplier'] ?? ''));
if ($name !== '') {
if ($supplierLocked) {
$selected = $this->supplierPortalAccess->supplierFor($portalUser);
abort_if(! $selected, 403, '当前钉钉账号未绑定有效供应商,请联系财务人员。');
$supplierQuery = $selected;
} else {
$mine = $this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:payment-reconciliation',
$companyId,
);
$suppliers = [];
foreach ($mine as $row) {
$name = trim((string) ($row['supplier'] ?? ''));
if ($name !== '') {
$suppliers[$name] = true;
}
}
foreach ($this->statements->uploadedSuppliers($companyId) as $name) {
$suppliers[$name] = true;
}
}
foreach ($this->statements->uploadedSuppliers($companyId) as $name) {
$suppliers[$name] = true;
}
foreach ($this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
$companyId,
) as $row) {
$name = trim((string) ($row['supplier'] ?? ''));
if ($name !== '') {
$suppliers[$name] = true;
foreach ($this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',
$companyId,
) as $row) {
$name = trim((string) ($row['supplier'] ?? ''));
if ($name !== '') {
$suppliers[$name] = true;
}
}
}
$supplierNames = array_keys($suppliers);
sort($supplierNames);
$supplierNames = array_keys($suppliers);
sort($supplierNames);
$supplierQuery = trim((string) request()->query('supplier', ''));
$selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : '';
$supplierNotFound = $supplierQuery !== '' && $selected === '';
if ($supplierQuery === '') {
$selected = $supplierNames[0] ?? '';
$supplierQuery = trim((string) request()->query('supplier', ''));
$selected = in_array($supplierQuery, $supplierNames, true) ? $supplierQuery : '';
$supplierNotFound = $supplierQuery !== '' && $selected === '';
if ($supplierQuery === '') {
$selected = $supplierNames[0] ?? '';
}
}
$page = max((int) request()->query('rows_page', 1), 1);
@ -192,6 +221,7 @@ class SupplierReconciliationPageData
'supplierQuery' => $supplierQuery,
'supplierNotFound' => $supplierNotFound,
'selectedSupplier' => $selected,
'supplierLocked' => $supplierLocked,
'uploads' => $selected !== '' ? $this->statements->uploads($selected, $companyId) : [],
'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE),
'statementTotal' => count($statementRows),

View File

@ -58,8 +58,8 @@ return [
'rejected' => 'Your DingTalk binding request was rejected. Contact the system administrator for access.',
'revoked' => 'Your DingTalk account binding was removed. Contact the system administrator to restore access.',
'session_expired' => 'Your DingTalk session is no longer valid. Sign in again, or contact the administrator if your account was disabled or unbound.',
'outside_roster' => 'Access is limited to current staff in the finance departments listed in the HR roster. Contact an administrator if your record needs correction.',
'roster_unavailable' => 'The finance roster cannot be verified right now. Please try DingTalk sign-in again later.',
'outside_roster' => 'Access is limited to current finance staff, individually authorized enterprise members, and active DingTalk members in supplier organizations. Contact an administrator if your record needs correction.',
'roster_unavailable' => 'The DingTalk roster or supplier organization cannot be verified right now. Please try DingTalk sign-in again later.',
],
'notification' => [

View File

@ -58,8 +58,8 @@ return [
'rejected' => '钉钉账号绑定申请已被拒绝,如需开通请联系系统管理员。',
'revoked' => '钉钉账号绑定已解除,如需恢复请联系系统管理员。',
'session_expired' => '钉钉登录已失效,请重新登录;如账号已停用或解除绑定,请联系系统管理员。',
'outside_roster' => '当前仅向财务中心及其下级部门的在职人员,以及已单独授权的本公司在职人员开放。如有疑问,请联系管理员核对登录资格。',
'roster_unavailable' => '暂时无法核验财务花名册,请稍后重新尝试钉钉登录。',
'outside_roster' => '当前仅向财务中心及其下级部门的在职人员、已单独授权的本公司在职人员,以及供应商组织下的在职钉钉成员开放。如有疑问,请联系管理员核对登录资格。',
'roster_unavailable' => '暂时无法核验钉钉花名册或供应商组织,请稍后重新尝试钉钉登录。',
],
'notification' => [

View File

@ -66,12 +66,12 @@
</td><td>{{ $row['deduction_rate_basis_points'] === null ? '—' : number_format($row['deduction_rate_basis_points'] / 100, 2) . '%' }}</td><td class="reship-money">{{ $row['deduction_cents'] === null ? '待核实' : number_format($row['deduction_cents'] / 100, 2) }}</td><td>{{ $row['deduction_note'] }}</td></tr>
@empty<tr><td colspan="14" class="reship-empty">{{ $reshipError ?: '当前筛选条件下暂无记录,请清空筛选或调整业务月份、分类。' }}</td></tr>@endforelse
</tbody></table></div>
<div class="reship-footer">当前分类共 {{ number_format($reshipRows->total()) }} 单,每页50单 {{ $reshipRows->links() }}</div>
<div class="reship-footer">当前分类共 {{ number_format($reshipRows->total()) }} 单,每页50单 {{ $reshipRows->links('business.partials.previous-next-pagination') }}</div>
</section>
@endif
</div>
<style>
.reship-main-tabs{margin-bottom:16px}.reship-page{color:var(--vip-content-ink,#171717);font-size:13px}.reship-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.reship-heading h2{font-size:16px;font-weight:600;margin:0 0 6px}.reship-heading p,.reship-range{color:#595959;font-size:12px}.reship-tag,.reship-status{padding:4px 8px;border-radius:4px;background:#F7F0DE;color:#856632;font-size:12px}.reship-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px;margin-bottom:16px}.reship-toolbar label{display:grid;gap:6px;font-size:12px}.reship-toolbar select,.reship-toolbar input,.reship-button,.reship-toolbar button{height:34px;padding:0 12px;border:1px solid #D8D2C7;border-radius:8px;background:#fff;color:#171717}.reship-button{display:inline-flex;align-items:center;text-decoration:none}.reship-toolbar .reship-primary{background:#856632;border-color:#856632;color:#fff}.reship-range{align-self:center;padding-top:18px}.reship-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.reship-metrics>div{padding:16px;background:#fff;border:1px solid #D8D2C7;border-radius:12px}.reship-metrics span{display:block;font-size:12px;color:#595959}.reship-metrics strong{display:block;margin-top:8px;font-size:22px;font-variant-numeric:tabular-nums}.reship-metrics>div:last-child strong{color:#856632}.reship-rules{margin:12px 0;color:#595959;font-size:12px}.reship-rules summary{cursor:pointer;color:#856632}.reship-rules p{margin:8px 0;line-height:1.7}.reship-error,.reship-notice{padding:12px;margin:12px 0;border-radius:8px;background:#F7F0DE;color:#935F07}.reship-error{color:#B80000}.reship-panel{border:1px solid #D8D2C7;border-radius:12px;background:#fff;overflow:hidden}.reship-tabs{display:flex;gap:24px;padding:0 16px;border-bottom:1px solid #D8D2C7}.reship-tabs a{padding:13px 0;color:#595959;text-decoration:none}.reship-tabs a.is-active{color:#856632;border-bottom:2px solid #856632}.reship-tabs span{font-size:11px;margin-left:5px}.reship-table-scroll{overflow:auto;max-height:65vh}.reship-page thead th{position:sticky;top:0;z-index:1}.reship-page table{width:100%;border-collapse:collapse;font-size:12px}.reship-page th,.reship-page td{padding:12px;text-align:left;vertical-align:top;border-bottom:1px solid #D8D2C7}.reship-page th{white-space:nowrap;background:#F7F0DE;font-weight:500;color:#595959}.reship-page td{min-width:110px;overflow-wrap:anywhere}.reship-page td.reship-barcode{min-width:200px;max-width:300px}.reship-page td.reship-money{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.reship-status{display:inline-block;white-space:nowrap}.reship-status.matched{background:#eef3e9;color:#53793E}.reship-status.unmatched{background:#f1efeb;color:#595959}.reship-candidate{min-width:110px;min-height:24px;margin-bottom:6px;line-height:24px;white-space:nowrap}.reship-candidate span{color:#595959}.reship-page td.reship-empty{text-align:center;padding:60px 16px;color:#595959}.reship-footer{padding:12px 16px;color:#595959;font-size:12px}.reship-footer nav{margin-top:12px}@media(max-width:700px){.reship-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.reship-range{width:100%;padding:0}.reship-tabs{gap:14px}.reship-metrics strong{font-size:19px}}
.reship-main-tabs{margin-bottom:16px}.reship-page{color:var(--vip-content-ink,#171717);font-size:13px}.reship-heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.reship-heading h2{font-size:16px;font-weight:600;margin:0 0 6px}.reship-heading p,.reship-range{color:#595959;font-size:12px}.reship-tag,.reship-status{padding:4px 8px;border-radius:4px;background:#F7F0DE;color:#856632;font-size:12px}.reship-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px;margin-bottom:16px}.reship-toolbar label{display:grid;gap:6px;font-size:12px}.reship-toolbar select,.reship-toolbar input,.reship-button,.reship-toolbar button{height:34px;padding:0 12px;border:1px solid #D8D2C7;border-radius:8px;background:#fff;color:#171717}.reship-button{display:inline-flex;align-items:center;text-decoration:none}.reship-toolbar .reship-primary{background:#856632;border-color:#856632;color:#fff}.reship-range{align-self:center;padding-top:18px}.reship-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-bottom:16px}.reship-metrics>div{padding:16px;background:#fff;border:1px solid #D8D2C7;border-radius:12px}.reship-metrics span{display:block;font-size:12px;color:#595959}.reship-metrics strong{display:block;margin-top:8px;font-size:22px;font-variant-numeric:tabular-nums}.reship-metrics>div:last-child strong{color:#856632}.reship-rules{margin:12px 0;color:#595959;font-size:12px}.reship-rules summary{cursor:pointer;color:#856632}.reship-rules p{margin:8px 0;line-height:1.7}.reship-error,.reship-notice{padding:12px;margin:12px 0;border-radius:8px;background:#F7F0DE;color:#935F07}.reship-error{color:#B80000}.reship-panel{border:1px solid #D8D2C7;border-radius:12px;background:#fff;overflow:hidden}.reship-tabs{display:flex;gap:24px;padding:0 16px;border-bottom:1px solid #D8D2C7}.reship-tabs a{padding:13px 0;color:#595959;text-decoration:none}.reship-tabs a.is-active{color:#856632;border-bottom:2px solid #856632}.reship-tabs span{font-size:11px;margin-left:5px}.reship-table-scroll{overflow:auto;max-height:65vh}.reship-page thead th{position:sticky;top:0;z-index:1}.reship-page table{width:100%;border-collapse:collapse;font-size:12px}.reship-page th,.reship-page td{padding:12px;text-align:left;vertical-align:top;border-bottom:1px solid #D8D2C7}.reship-page th{white-space:nowrap;background:#F7F0DE;font-weight:500;color:#595959}.reship-page td{min-width:110px;overflow-wrap:anywhere}.reship-page td.reship-barcode{min-width:200px;max-width:300px}.reship-page td.reship-money{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.reship-status{display:inline-block;white-space:nowrap}.reship-status.matched{background:#eef3e9;color:#53793E}.reship-status.unmatched{background:#f1efeb;color:#595959}.reship-candidate{min-width:110px;min-height:24px;margin-bottom:6px;line-height:24px;white-space:nowrap}.reship-candidate span{color:#595959}.reship-page td.reship-empty{text-align:center;padding:60px 16px;color:#595959}.reship-footer{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;padding:12px 16px;color:#595959;font-size:12px}.reship-footer nav{margin:0}@media(max-width:700px){.reship-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.reship-range{width:100%;padding:0}.reship-tabs{gap:14px}.reship-metrics strong{font-size:19px}}
</style>
@push('body_end')
<script>

View File

@ -32,12 +32,12 @@
</td></tr>
@empty<tr><td colspan="7" class="reship-empty">暂无符合条件的快递配置。</td></tr>@endforelse
</tbody></table></div>
<div class="reship-footer">共 {{ $configRows->total() }} 条,每页20条 {{ $configRows->links() }}</div>
<div class="reship-footer">共 {{ $configRows->total() }} 条,每页20条 {{ $configRows->links('business.partials.previous-next-pagination') }}</div>
</section>
<h3 class="reship-config-log-title">操作日志</h3>
<section class="reship-panel"><div class="reship-table-scroll"><table><thead><tr><th>操作时间</th><th>操作用户</th><th>系统功能</th><th>操作类型</th><th>执行结果</th><th>执行结果描述</th></tr></thead><tbody>
@forelse ($configEvents as $event)<tr><td>{{ $event->created_at }}</td><td>{{ $event->actor }}</td><td>配件补发扣款配置</td><td>{{ ['create'=>'新增','update'=>'编辑','delete'=>'删除','restore'=>'恢复','import'=>'导入'][$event->action] ?? $event->action }}</td><td>成功</td><td>{{ $event->description }}</td></tr>@empty<tr><td colspan="6" class="reship-empty">暂无操作记录。</td></tr>@endforelse
</tbody></table></div><div class="reship-footer">{{ $configEvents->links() }}</div></section>
</tbody></table></div><div class="reship-footer">{{ $configEvents->links('business.partials.previous-next-pagination') }}</div></section>
<dialog class="reship-config-dialog" data-config-editor>
<form method="POST" action="{{ route('business.accessory-reship-configs.store') }}" data-create-url="{{ route('business.accessory-reship-configs.store') }}">
@csrf<input type="hidden" name="_method" value="POST">

View File

@ -865,6 +865,7 @@
<div><dt>原始范围</dt><dd data-quote-detail="range"></dd></div>
<div><dt>执行日期</dt><dd data-quote-detail="date"></dd></div>
<div><dt>公式单元格</dt><dd data-quote-detail="formulas"></dd></div>
<div><dt>系统计费规则</dt><dd data-quote-detail="billing"></dd></div>
</dl>
<div class="quote-signed" data-quote-signed hidden>
<span class="material-icons-outlined" aria-hidden="true">task_alt</span>
@ -1180,6 +1181,7 @@
setDetail('range', text(quote.source_range, '-'));
setDetail('date', text(quote.period_label || quote.effective_date, 'Excel 未标注'));
setDetail('formulas', String(quote.formula_count));
setDetail('billing', text(quote.billing_summary, '未设置(运费需人工核算)'));
var originals = root.querySelector('[data-stock-original-links]');
originals.replaceChildren();
(quote.stock_originals || []).forEach(function (file, index, files) {

View File

@ -211,7 +211,7 @@
</table>
</div>
@if (($shippingRows ?? null) instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator && $shippingRows->hasPages())
<div class="flex items-center justify-between border-t border-[#eee8de] px-4 py-3 text-sm text-gray-500"><span>共 {{ $shippingRows->total() }} 条记录</span><div>{{ $shippingRows->onEachSide(1)->links() }}</div></div>
<div class="flex items-center justify-between border-t border-[#eee8de] px-4 py-3 text-sm text-gray-500"><span>共 {{ $shippingRows->total() }} 条记录</span><div>{{ $shippingRows->links('business.partials.previous-next-pagination') }}</div></div>
@endif
</div>
</div>

View File

@ -485,14 +485,14 @@
href="{{ $rows->previousPageUrl() ?: '#' }}"
aria-label="上一页"
@if ($rows->onFirstPage()) aria-disabled="true" @endif
><span class="material-icons-outlined" aria-hidden="true">chevron_left</span></a>
>上一页</a>
<span>第 {{ $rows->currentPage() }} / {{ max($rows->lastPage(), 1) }} 页</span>
<a
@class(['third-party-warehouse-button', 'is-disabled' => ! $rows->hasMorePages()])
href="{{ $rows->nextPageUrl() ?: '#' }}"
aria-label="下一页"
@if (! $rows->hasMorePages()) aria-disabled="true" @endif
><span class="material-icons-outlined" aria-hidden="true">chevron_right</span></a>
>下一页</a>
</div>
@endif
</div>

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