parent
e7eb1c9398
commit
9260549d83
@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\DingProcessPaymentLineAllocator;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private function connection(): string
|
||||
{
|
||||
return (string) config('upstream_mirrors.connection', 'business_mirror');
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
$schema = Schema::connection($this->connection());
|
||||
if (! $schema->hasTable('ding_process_detail_mirror')
|
||||
|| ! $schema->hasTable('ding_process_payment_mirror')) {
|
||||
return;
|
||||
}
|
||||
if (! $schema->hasTable('ding_process_payment_line_mirror')) {
|
||||
$schema->create('ding_process_payment_line_mirror', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('source_scope', 40)->default('personal_permission');
|
||||
$table->string('business_id', 32);
|
||||
$table->string('process_instance_id', 100)->nullable();
|
||||
$table->unsignedInteger('payment_no');
|
||||
$table->unsignedInteger('line_no');
|
||||
$table->string('paid_month', 7)->default('');
|
||||
$table->string('paid_at', 50)->nullable();
|
||||
$table->decimal('amount', 18, 2);
|
||||
$table->string('account_code', 64)->nullable();
|
||||
$table->string('account_name', 255)->nullable();
|
||||
$table->string('payment_method', 50)->nullable();
|
||||
$table->dateTime('synced_at', 6);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(
|
||||
['source_scope', 'business_id', 'payment_no', 'line_no'],
|
||||
'ding_payment_line_payment_line_unique',
|
||||
);
|
||||
$table->index(
|
||||
['source_scope', 'paid_month'],
|
||||
'ding_payment_line_scope_month_index',
|
||||
);
|
||||
$table->index(
|
||||
['source_scope', 'process_instance_id'],
|
||||
'ding_payment_line_scope_process_index',
|
||||
);
|
||||
$table->index(
|
||||
['source_scope', 'business_id', 'line_no'],
|
||||
'ding_payment_line_scope_business_line_index',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
$this->backfill($schema->getConnection());
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (app()->environment('testing')
|
||||
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::connection($this->connection())
|
||||
->dropIfExists('ding_process_payment_line_mirror');
|
||||
}
|
||||
|
||||
private function backfill(\Illuminate\Database\ConnectionInterface $database): void
|
||||
{
|
||||
$allocator = app(DingProcessPaymentLineAllocator::class);
|
||||
$now = now();
|
||||
$scopes = $database->table('ding_process_payment_mirror')
|
||||
->distinct()
|
||||
->pluck('source_scope');
|
||||
|
||||
foreach ($scopes as $scopeValue) {
|
||||
$scope = trim((string) $scopeValue);
|
||||
if ($scope === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lastId = 0;
|
||||
do {
|
||||
$payments = $database->table('ding_process_payment_mirror')
|
||||
->where('source_scope', $scope)
|
||||
->where('id', '>', $lastId)
|
||||
->orderBy('id')
|
||||
->limit(500)
|
||||
->get()
|
||||
->map(static fn (object $row): array => (array) $row)
|
||||
->all();
|
||||
if ($payments === []) {
|
||||
break;
|
||||
}
|
||||
$lastId = (int) end($payments)['id'];
|
||||
|
||||
$businessIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn (array $row): string => trim((string) ($row['business_id'] ?? '')),
|
||||
$payments,
|
||||
))));
|
||||
$details = $database->table('ding_process_detail_mirror')
|
||||
->where('source_scope', $scope)
|
||||
->whereIn('business_id', $businessIds)
|
||||
->get()
|
||||
->map(static fn (object $row): array => (array) $row)
|
||||
->all();
|
||||
$rows = $allocator->allocate($details, $payments, $scope, $now);
|
||||
foreach (array_chunk($rows, 500) as $chunk) {
|
||||
$database->table('ding_process_payment_line_mirror')->insert($chunk);
|
||||
}
|
||||
} while (count($payments) === 500);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -33,7 +33,7 @@
|
||||
| 已接入待首次同步 | 店铺营销账户余额的京东账户读取 `js_data_middle_platform.dbo.jd_jzt_finance_records` 镜像 `jsm_jd_jzt_finance_records`;按 `id` 稳定断点、`updated_at` 重叠增量,页面不直连上游。 |
|
||||
| 本次接入 | 普通商品资料的工作平台采购价格接入 `jiansun.dbo.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 |
|
||||
| 待有效凭据后首次同步 | 普通商品当前成本价、商品类型和更新时间继续读取 `operations_replica.rpt.jst_sku`;`rpt.fys_skuCostPriceUpdate` 是一 SKU 一行的旧快照,不能充当变价历史。完整成本价变更仍读取聚水潭经典开放平台“商品操作日志查询”接口 `worklog.query`,只镜像操作内容含“采购价”的记录到 `jushuitan_cost_price_history_rows`;经典接口必须在服务器私有配置中使用同一应用的 `JUSHUITAN_PARTNER_ID / JUSHUITAN_PARTNER_KEY / JUSHUITAN_TOKEN`,不得把新开放平台的 App Key / App Secret / Access Token 混用。页面在首次同步完成前明确显示待同步或失败,不把 0 条解释为真实无历史。 |
|
||||
| 本次接入 | 流程明细读取 `operations_replica/rpt.ding_Workflow + rpt.ding_WorkflowScheme` 的结构化审批主表、费用明细和付款明细,写入 `ding_process_detail_mirror` 与 `ding_process_payment_mirror`;主表的 `originatorDeptName` 作为发起人部门单独保留。禁止先把明细表和付款表铺平成 CSV 后逐行求和;付款明细必须按“对应序号”关联到代表明细行,但合计始终读取独立付款事实。每个纳入范围的源审批至少保留一条页面记录,没有费用金额的审批以空金额显示,不静默隐藏。首次全量先运行 `business-mirror:sync-ding-process-details --full --dry-run` 校验,再使用 `business-mirror:sync-ding-process-details --full` 发布;日常通过 `updateAt/createAt` 48 小时重叠增量同步。当前纳入日常报销、应付、线上代发、种菜、快递面单、固定资产、日常物品、样品、一次性采买、板材、工资、外贸费用和种菜充值等 14 个财务流程;发现新的明细流程编码时全量同步报错并保留上一版。 |
|
||||
| 本次接入 | 流程明细读取 `operations_replica/rpt.ding_Workflow + rpt.ding_WorkflowScheme` 的结构化审批主表、费用明细和付款明细,写入 `ding_process_detail_mirror`、`ding_process_payment_mirror` 与付款按明细行分摊的 `ding_process_payment_line_mirror`;主表的 `originatorDeptName` 作为发起人部门单独保留。禁止先把明细表和付款表铺平成 CSV 后逐行求和;付款明细必须按“对应序号”关联到代表明细行,但合计始终读取独立付款事实。管理费用按分摊后的付款月份统计,跨月分笔付款不会把整张审批重复计入多月。每个纳入范围的源审批至少保留一条页面记录,没有费用金额的审批以空金额显示,不静默隐藏。首次全量先运行 `business-mirror:sync-ding-process-details --full --dry-run` 校验,再使用 `business-mirror:sync-ding-process-details --full` 发布;日常通过 `updateAt/createAt` 48 小时重叠增量同步。当前纳入日常报销、应付、线上代发、种菜、快递面单、固定资产、日常物品、样品、一次性采买、板材、工资、外贸费用和种菜充值等 14 个财务流程;发现新的明细流程编码时全量同步报错并保留上一版。 |
|
||||
| 本次接入 | 财务经营报表 › 管理费用使用 `oa_process_account_mapping` 保存流程科目映射;当前快照包含原工作簿 191 个科目和补充的 `5601.11.01 货代费`、`1221.07 种菜备用金`、`5602.11.03.19 AI阿里云软件费`,共 194 个科目,其中 37 个进费比科目和 126 个分摊规则。页面按打款时间提供整月选择并默认展示上个月 OA 流程中审批状态为“已完成”、涉及付款且出纳已打款的明细,不涉及付款或出纳未打款的数据不进入汇总,按费用大类和二级汇总进费比/不进费比/未映射金额,点击二级查看三级汇总与 9 列明细模板。审批完成时间写入 `ding_process_detail_mirror.approval_finished_at`,页面月份筛选使用 `ding_process_detail_mirror.paid_at`;钉钉副本把费用明细表保存为未命名字段时,按配置中的金额列识别包含该列的未命名表格组件,避免只抓到付款明细而漏掉费用科目和金额。明细行没有科目时,使用同一审批表单表头的 `科目编码 / 科目名称` 回填;行级科目始终优先。 |
|
||||
|
||||
流程明细另设独立定时任务,避免与每分钟科目映射消费争用同一上游目录时被静默跳过。正式发布脚本会覆盖安装 `/etc/cron.d/caiwu-ding-process-details`:每 5 分钟尝试运行 `business-mirror:sync-ding-process-details`,使用独立调度锁并在共用上游锁上最多等待 15 分钟;`scripts/install-ding-process-sync-cron.ps1` 仅作为手工修复入口。任务时间和镜像状态统一使用平台固定时区,审批等待时长按实际小时展示。OA 页面每小时最多把一次当前页“审批中”的审批编号写入定向刷新队列,由每分钟检查一次的批处理命令消费;其他时间直接展示最近镜像,已完成流程不进入即时刷新链路,仍由常规增量和每周全量校准处理付款等后续变化。增量同步除跟进审批主表更新时间外,还以 `ding_WorkflowScheme` 的数字 `id` 高水位发现后补表单行,避免付款明细等新组件写入后主审批 `updateAt` 不变而被漏掉;原地改写同一组件仍由每周全量校准兜底。付款状态只按钉钉“出纳打款”节点判断,分为“出纳状态待同步 / 出纳未开始 / 出纳待打款 / 出纳退回 / 出纳已打款 / 流程终止 / 不涉及付款”。金额差异不参与付款状态判断,另以“付款差额 = 费用合计 − 已付款合计”展示。不能因为金额存在少量差异就覆盖出纳节点事实,也不能因为出纳已通过就隐藏金额差异。
|
||||
|
||||
@ -44,6 +44,7 @@ class DingProcessDetailMirrorSynchronizer
|
||||
DatabaseManager $database,
|
||||
private readonly UpstreamReadOnlyQueryFactory $queries,
|
||||
private readonly DingTalkProcessInstanceClient $approvalClient,
|
||||
private readonly DingProcessPaymentLineAllocator $paymentLineAllocator,
|
||||
) {
|
||||
$this->connection = $database->connection(
|
||||
(string) config('upstream_mirrors.connection', 'business_mirror'),
|
||||
@ -2261,6 +2262,12 @@ class DingProcessDetailMirrorSynchronizer
|
||||
Carbon $now,
|
||||
): void {
|
||||
$previous = $this->state();
|
||||
$paymentLines = $this->paymentLineAllocator->allocate(
|
||||
$details,
|
||||
$payments,
|
||||
$scope,
|
||||
$now,
|
||||
);
|
||||
$details = array_map(function (array $row): array {
|
||||
unset($row['amount_cents']);
|
||||
|
||||
@ -2281,8 +2288,12 @@ class DingProcessDetailMirrorSynchronizer
|
||||
$metrics,
|
||||
$now,
|
||||
$previous,
|
||||
$paymentLines,
|
||||
): void {
|
||||
if ($full) {
|
||||
$this->connection->table('ding_process_payment_line_mirror')
|
||||
->where('source_scope', $scope)
|
||||
->delete();
|
||||
$this->connection->table('ding_process_payment_mirror')
|
||||
->where('source_scope', $scope)
|
||||
->delete();
|
||||
@ -2291,6 +2302,10 @@ class DingProcessDetailMirrorSynchronizer
|
||||
->delete();
|
||||
} elseif ($processInstanceIds !== []) {
|
||||
foreach (array_chunk($processInstanceIds, 500) as $chunk) {
|
||||
$this->connection->table('ding_process_payment_line_mirror')
|
||||
->where('source_scope', $scope)
|
||||
->whereIn('process_instance_id', $chunk)
|
||||
->delete();
|
||||
$this->connection->table('ding_process_payment_mirror')
|
||||
->where('source_scope', $scope)
|
||||
->whereIn('process_instance_id', $chunk)
|
||||
@ -2308,6 +2323,9 @@ class DingProcessDetailMirrorSynchronizer
|
||||
foreach (array_chunk($payments, 500) as $chunk) {
|
||||
$this->connection->table('ding_process_payment_mirror')->insert($chunk);
|
||||
}
|
||||
foreach (array_chunk($paymentLines, 500) as $chunk) {
|
||||
$this->connection->table('ding_process_payment_line_mirror')->insert($chunk);
|
||||
}
|
||||
|
||||
$detailCount = (int) $this->connection->table('ding_process_detail_mirror')
|
||||
->where('source_scope', $scope)
|
||||
@ -2315,6 +2333,9 @@ class DingProcessDetailMirrorSynchronizer
|
||||
$paymentCount = (int) $this->connection->table('ding_process_payment_mirror')
|
||||
->where('source_scope', $scope)
|
||||
->count();
|
||||
$paymentLineCount = (int) $this->connection->table('ding_process_payment_line_mirror')
|
||||
->where('source_scope', $scope)
|
||||
->count();
|
||||
$amountTotal = (float) ($this->connection->table('ding_process_detail_mirror')
|
||||
->where('source_scope', $scope)
|
||||
->sum('amount') ?? 0);
|
||||
@ -2339,6 +2360,7 @@ class DingProcessDetailMirrorSynchronizer
|
||||
'synced_payments' => $metrics['payments'],
|
||||
'mirror_details' => $detailCount,
|
||||
'mirror_payments' => $paymentCount,
|
||||
'mirror_payment_lines' => $paymentLineCount,
|
||||
'amount_total' => round($amountTotal, 2),
|
||||
'bank_amount_total' => round($bankAmountTotal, 2),
|
||||
'scheme_high_watermark' => (int) ($metrics['scheme_high_watermark'] ?? 0),
|
||||
|
||||
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final class DingProcessPaymentLineAllocator
|
||||
{
|
||||
/**
|
||||
* Allocate every structured payment to its referenced OA detail lines.
|
||||
*
|
||||
* @param list<array<string,mixed>> $details
|
||||
* @param list<array<string,mixed>> $payments
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function allocate(
|
||||
array $details,
|
||||
array $payments,
|
||||
string $scope,
|
||||
Carbon $now,
|
||||
): array {
|
||||
$detailsByBusiness = [];
|
||||
foreach ($details as $detail) {
|
||||
$businessId = trim((string) ($detail['business_id'] ?? ''));
|
||||
$lineNo = (int) ($detail['line_no'] ?? 0);
|
||||
if ($businessId === '' || $lineNo <= 0) {
|
||||
continue;
|
||||
}
|
||||
$detailsByBusiness[$businessId][$lineNo] = [
|
||||
'amount_cents' => $this->amountCents($detail),
|
||||
];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($payments as $payment) {
|
||||
$businessId = trim((string) ($payment['business_id'] ?? ''));
|
||||
$paymentNo = (int) ($payment['payment_no'] ?? 0);
|
||||
$amountCents = $this->amountCents($payment);
|
||||
$businessDetails = $detailsByBusiness[$businessId] ?? [];
|
||||
if ($businessId === '' || $paymentNo <= 0 || $amountCents === 0 || $businessDetails === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lineNumbers = $this->lineNumbers(
|
||||
(string) ($payment['sequence_spec'] ?? ''),
|
||||
array_keys($businessDetails),
|
||||
);
|
||||
if ($lineNumbers === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$allocated = $this->allocateCents(
|
||||
$amountCents,
|
||||
array_map(
|
||||
static fn (int $lineNo): int => (int) $businessDetails[$lineNo]['amount_cents'],
|
||||
$lineNumbers,
|
||||
),
|
||||
);
|
||||
$paidAt = trim((string) ($payment['paid_at'] ?? ''));
|
||||
$paidMonth = preg_match('/^\d{4}-\d{2}/', $paidAt, $matches) === 1
|
||||
? $matches[0]
|
||||
: '';
|
||||
|
||||
foreach ($lineNumbers as $index => $lineNo) {
|
||||
$rows[] = [
|
||||
'source_scope' => $scope,
|
||||
'business_id' => $businessId,
|
||||
'process_instance_id' => (string) ($payment['process_instance_id'] ?? ''),
|
||||
'payment_no' => $paymentNo,
|
||||
'line_no' => $lineNo,
|
||||
'paid_month' => $paidMonth,
|
||||
'paid_at' => $paidAt,
|
||||
'amount' => number_format($allocated[$index] / 100, 2, '.', ''),
|
||||
'account_code' => $payment['account_code'] ?? null,
|
||||
'account_name' => $payment['account_name'] ?? null,
|
||||
'payment_method' => $payment['payment_method'] ?? null,
|
||||
'synced_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function amountCents(array $row): int
|
||||
{
|
||||
if (array_key_exists('amount_cents', $row)) {
|
||||
return (int) $row['amount_cents'];
|
||||
}
|
||||
|
||||
return (int) round((float) ($row['amount'] ?? 0) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $availableLines
|
||||
* @return list<int>
|
||||
*/
|
||||
private function lineNumbers(string $sequenceSpec, array $availableLines): array
|
||||
{
|
||||
preg_match_all('/\d+/', $sequenceSpec, $matches);
|
||||
$requested = array_values(array_unique(array_map('intval', $matches[0] ?? [])));
|
||||
$matched = array_values(array_intersect($requested, $availableLines));
|
||||
if ($matched !== []) {
|
||||
return $matched;
|
||||
}
|
||||
if ($requested !== []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
sort($availableLines);
|
||||
|
||||
return $availableLines === [] ? [] : [$availableLines[0]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $weights
|
||||
* @return list<int>
|
||||
*/
|
||||
private function allocateCents(int $amountCents, array $weights): array
|
||||
{
|
||||
$count = count($weights);
|
||||
if ($count === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$totalWeight = array_sum($weights);
|
||||
if ($totalWeight <= 0) {
|
||||
$base = intdiv($amountCents, $count);
|
||||
$allocated = array_fill(0, $count, $base);
|
||||
$allocated[$count - 1] += $amountCents - ($base * $count);
|
||||
|
||||
return $allocated;
|
||||
}
|
||||
|
||||
$allocated = [];
|
||||
$used = 0;
|
||||
foreach ($weights as $index => $weight) {
|
||||
if ($index === $count - 1) {
|
||||
$value = $amountCents - $used;
|
||||
} else {
|
||||
$value = intdiv($amountCents * $weight, $totalWeight);
|
||||
$used += $value;
|
||||
}
|
||||
$allocated[] = $value;
|
||||
}
|
||||
|
||||
return $allocated;
|
||||
}
|
||||
}
|
||||
@ -159,7 +159,7 @@ class ManagementExpensesPageData
|
||||
->selectRaw("{$category} AS expense_category, {$second} AS second_level")
|
||||
->selectRaw($this->amountSplitExpression())
|
||||
->groupByRaw("{$category}, {$second}")
|
||||
->orderByRaw('SUM(COALESCE(d.amount, 0)) DESC')
|
||||
->orderByRaw('SUM(COALESCE(paid_line.paid_amount, 0)) DESC')
|
||||
->orderByRaw("{$category}, {$second}")
|
||||
->get();
|
||||
|
||||
@ -190,7 +190,7 @@ class ManagementExpensesPageData
|
||||
->selectRaw("{$category} AS expense_category, {$second} AS second_level, {$third} AS third_level")
|
||||
->selectRaw($this->amountSplitExpression())
|
||||
->groupByRaw("{$category}, {$second}, {$third}")
|
||||
->orderByRaw('SUM(COALESCE(d.amount, 0)) DESC')
|
||||
->orderByRaw('SUM(COALESCE(paid_line.paid_amount, 0)) DESC')
|
||||
->orderByRaw("{$third}")
|
||||
->get();
|
||||
|
||||
@ -221,16 +221,16 @@ class ManagementExpensesPageData
|
||||
'd.business_id',
|
||||
'd.originator_department',
|
||||
'd.purpose',
|
||||
'd.amount',
|
||||
'd.line_no',
|
||||
'd.process_instance_id',
|
||||
])
|
||||
->selectRaw('paid_line.paid_amount AS amount')
|
||||
->selectRaw($this->categoryExpression() . ' AS expense_category')
|
||||
->selectRaw($this->secondLevelExpression() . ' AS second_level')
|
||||
->selectRaw($this->thirdLevelExpression() . ' AS third_level')
|
||||
->selectRaw($this->feeRatioExpression() . ' AS fee_ratio')
|
||||
->selectRaw($this->allocationExpression() . ' AS allocation')
|
||||
->orderByDesc('d.paid_at')
|
||||
->orderByDesc('paid_line.paid_month')
|
||||
->orderByDesc('d.business_id')
|
||||
->orderBy('d.line_no')
|
||||
->forPage($page, $perPage)
|
||||
@ -280,38 +280,35 @@ class ManagementExpensesPageData
|
||||
|
||||
private function baseQuery(array $filters): Builder
|
||||
{
|
||||
$paidLines = $this->connection->table('ding_process_payment_line_mirror')
|
||||
->select('source_scope', 'business_id', 'line_no', 'paid_month')
|
||||
->selectRaw('SUM(amount) AS paid_amount')
|
||||
->groupBy('source_scope', 'business_id', 'line_no', 'paid_month');
|
||||
|
||||
$query = $this->connection->table('ding_process_detail_mirror as d')
|
||||
->joinSub($paidLines, 'paid_line', function ($join): void {
|
||||
$join
|
||||
->on('paid_line.source_scope', '=', 'd.source_scope')
|
||||
->on('paid_line.business_id', '=', 'd.business_id')
|
||||
->on('paid_line.line_no', '=', 'd.line_no');
|
||||
})
|
||||
->leftJoin(
|
||||
'oa_process_account_mapping as m',
|
||||
'm.account_code',
|
||||
'=',
|
||||
'd.account_code',
|
||||
)
|
||||
->where('d.cashier_status', 'completed')
|
||||
->where(function (Builder $terminated): void {
|
||||
$terminated->whereNull('d.cashier_status')
|
||||
->orWhere('d.cashier_status', '<>', 'terminated');
|
||||
})
|
||||
->where(function (Builder $paymentSource): void {
|
||||
$paymentSource->whereNull('d.payment_source_state')
|
||||
->orWhere('d.payment_source_state', '<>', 'not_applicable');
|
||||
});
|
||||
|
||||
if (($filters['paid_month'] ?? '') !== '') {
|
||||
$monthStart = \Illuminate\Support\Carbon::createFromFormat(
|
||||
'Y-m-d',
|
||||
$filters['paid_month'] . '-01',
|
||||
)->startOfDay();
|
||||
$monthEnd = $monthStart->copy()->addMonthNoOverflow()->toDateString();
|
||||
$query->whereExists(function (Builder $paidApproval) use ($monthStart, $monthEnd): void {
|
||||
$paidApproval
|
||||
->selectRaw('1')
|
||||
->from('ding_process_detail_mirror as paid_detail')
|
||||
->whereColumn('paid_detail.business_id', 'd.business_id')
|
||||
->where('paid_detail.cashier_status', 'completed')
|
||||
->where(function (Builder $paymentSource): void {
|
||||
$paymentSource->whereNull('paid_detail.payment_source_state')
|
||||
->orWhere('paid_detail.payment_source_state', '<>', 'not_applicable');
|
||||
})
|
||||
->where('paid_detail.paid_at', '>=', $monthStart->toDateString())
|
||||
->where('paid_detail.paid_at', '<', $monthEnd);
|
||||
});
|
||||
$query->where('paid_line.paid_month', (string) $filters['paid_month']);
|
||||
}
|
||||
|
||||
if (($filters['expense_category'] ?? '') !== '') {
|
||||
@ -365,11 +362,11 @@ class ManagementExpensesPageData
|
||||
{
|
||||
return 'COUNT(*) AS row_count, '
|
||||
. 'COUNT(DISTINCT d.business_id) AS approval_count, '
|
||||
. 'SUM(COALESCE(d.amount, 0)) AS amount_total, '
|
||||
. 'SUM(CASE WHEN m.in_fee_ratio = 1 THEN COALESCE(d.amount, 0) ELSE 0 END) AS in_fee_ratio_amount, '
|
||||
. 'SUM(COALESCE(paid_line.paid_amount, 0)) AS amount_total, '
|
||||
. 'SUM(CASE WHEN m.in_fee_ratio = 1 THEN COALESCE(paid_line.paid_amount, 0) ELSE 0 END) AS in_fee_ratio_amount, '
|
||||
. 'SUM(CASE WHEN m.account_code IS NOT NULL AND m.in_fee_ratio = 0 '
|
||||
. 'THEN COALESCE(d.amount, 0) ELSE 0 END) AS non_fee_ratio_amount, '
|
||||
. 'SUM(CASE WHEN m.account_code IS NULL THEN COALESCE(d.amount, 0) ELSE 0 END) AS unmapped_amount';
|
||||
. 'THEN COALESCE(paid_line.paid_amount, 0) ELSE 0 END) AS non_fee_ratio_amount, '
|
||||
. 'SUM(CASE WHEN m.account_code IS NULL THEN COALESCE(paid_line.paid_amount, 0) ELSE 0 END) AS unmapped_amount';
|
||||
}
|
||||
|
||||
private function categoryExpression(): string
|
||||
|
||||
@ -101,6 +101,10 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
|
||||
->table('ding_process_detail_mirror')->count());
|
||||
$this->assertSame(1, DB::connection('business_mirror')
|
||||
->table('ding_process_payment_mirror')->count());
|
||||
$this->assertSame(14, DB::connection('business_mirror')
|
||||
->table('ding_process_payment_line_mirror')->count());
|
||||
$this->assertEqualsWithDelta(1114.57, (float) DB::connection('business_mirror')
|
||||
->table('ding_process_payment_line_mirror')->sum('amount'), 0.001);
|
||||
$this->assertEqualsWithDelta(1114.57, (float) DB::connection('business_mirror')
|
||||
->table('ding_process_detail_mirror')->sum('bank_amount'), 0.001);
|
||||
$this->assertSame('present', DB::connection('business_mirror')
|
||||
@ -1412,6 +1416,26 @@ class DingProcessDetailMirrorSynchronizerTest extends TestCase
|
||||
$table->unique(['source_scope', 'business_id', 'payment_no']);
|
||||
},
|
||||
);
|
||||
Schema::connection('business_mirror')->create(
|
||||
'ding_process_payment_line_mirror',
|
||||
function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('source_scope', 40);
|
||||
$table->string('business_id', 32);
|
||||
$table->string('process_instance_id', 100)->nullable();
|
||||
$table->unsignedInteger('payment_no');
|
||||
$table->unsignedInteger('line_no');
|
||||
$table->string('paid_month', 7)->default('');
|
||||
$table->string('paid_at', 50)->nullable();
|
||||
$table->decimal('amount', 18, 2);
|
||||
$table->string('account_code', 64)->nullable();
|
||||
$table->string('account_name', 255)->nullable();
|
||||
$table->string('payment_method', 50)->nullable();
|
||||
$table->dateTime('synced_at', 6);
|
||||
$table->timestamps();
|
||||
$table->unique(['source_scope', 'business_id', 'payment_no', 'line_no']);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Modules\EcommerceFinance\Services\DingProcessPaymentLineAllocator;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DingProcessPaymentLineAllocatorTest extends TestCase
|
||||
{
|
||||
public function test_it_splits_a_multi_line_payment_across_referenced_details(): void
|
||||
{
|
||||
$rows = (new DingProcessPaymentLineAllocator())->allocate(
|
||||
[
|
||||
['business_id' => 'approval-2', 'line_no' => 1, 'amount' => '100.00'],
|
||||
['business_id' => 'approval-2', 'line_no' => 2, 'amount' => '200.00'],
|
||||
],
|
||||
[[
|
||||
'business_id' => 'approval-2',
|
||||
'process_instance_id' => 'process-2',
|
||||
'payment_no' => 1,
|
||||
'sequence_spec' => '1-2',
|
||||
'amount' => '300.00',
|
||||
'paid_at' => '2026-10-15 00:00',
|
||||
]],
|
||||
'personal_permission',
|
||||
Carbon::parse('2026-11-02 12:00:00'),
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
[[1, 100.0], [2, 200.0]],
|
||||
array_map(
|
||||
static fn (array $row): array => [
|
||||
(int) $row['line_no'],
|
||||
(float) $row['amount'],
|
||||
],
|
||||
$rows,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_allocates_partial_payments_to_the_correct_months(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-11-02 12:00:00');
|
||||
|
||||
try {
|
||||
$rows = (new DingProcessPaymentLineAllocator())->allocate(
|
||||
[
|
||||
['business_id' => 'approval-1', 'line_no' => 1, 'amount' => '100.00'],
|
||||
['business_id' => 'approval-1', 'line_no' => 2, 'amount' => '50.00'],
|
||||
],
|
||||
[
|
||||
[
|
||||
'business_id' => 'approval-1',
|
||||
'process_instance_id' => 'process-1',
|
||||
'payment_no' => 1,
|
||||
'sequence_spec' => '1',
|
||||
'amount' => '40.00',
|
||||
'paid_at' => '2026-09-30 00:00',
|
||||
],
|
||||
[
|
||||
'business_id' => 'approval-1',
|
||||
'process_instance_id' => 'process-1',
|
||||
'payment_no' => 2,
|
||||
'sequence_spec' => '1',
|
||||
'amount' => '60.00',
|
||||
'paid_at' => '2026-10-15 00:00',
|
||||
],
|
||||
[
|
||||
'business_id' => 'approval-1',
|
||||
'process_instance_id' => 'process-1',
|
||||
'payment_no' => 3,
|
||||
'sequence_spec' => '2',
|
||||
'amount' => '50.00',
|
||||
'paid_at' => '2026-10-20 00:00',
|
||||
],
|
||||
],
|
||||
'personal_permission',
|
||||
Carbon::parse('2026-11-02 12:00:00'),
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
[
|
||||
['2026-09', '1', 40.0],
|
||||
['2026-10', '1', 60.0],
|
||||
['2026-10', '2', 50.0],
|
||||
],
|
||||
array_map(
|
||||
static fn (array $row): array => [
|
||||
$row['paid_month'],
|
||||
(string) $row['line_no'],
|
||||
(float) $row['amount'],
|
||||
],
|
||||
$rows,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
Carbon::setTestNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -103,7 +103,7 @@ class ManagementExpensesPageDataTest extends TestCase
|
||||
array_values(ManagementExpensesPageData::DETAIL_COLUMNS),
|
||||
);
|
||||
$this->assertSame(
|
||||
['202609251200000000008', '202610011200000000001'],
|
||||
['202610011200000000001', '202609251200000000008'],
|
||||
array_column($data['rows'], 'business_id'),
|
||||
);
|
||||
$this->assertSame('不进费比', $data['rows'][0]['fee_ratio']);
|
||||
@ -132,7 +132,7 @@ class ManagementExpensesPageDataTest extends TestCase
|
||||
1,
|
||||
10,
|
||||
'审批中',
|
||||
'completed',
|
||||
'pending_sync',
|
||||
'present',
|
||||
'2026-10-20 10:00:00',
|
||||
'2026-10-20 00:00',
|
||||
@ -143,7 +143,7 @@ class ManagementExpensesPageDataTest extends TestCase
|
||||
2,
|
||||
15,
|
||||
'审批中',
|
||||
'completed',
|
||||
'pending_sync',
|
||||
'present',
|
||||
'2026-10-20 10:00:00',
|
||||
null,
|
||||
@ -151,6 +151,26 @@ class ManagementExpensesPageDataTest extends TestCase
|
||||
DB::connection('business_mirror')
|
||||
->table('ding_process_detail_mirror')
|
||||
->insert([$first, $second]);
|
||||
DB::connection('business_mirror')
|
||||
->table('ding_process_payment_mirror')
|
||||
->insert([
|
||||
'business_id' => $businessId,
|
||||
'process_instance_id' => 'process-' . $businessId,
|
||||
'payment_no' => 1,
|
||||
'sequence_spec' => '1-2',
|
||||
'amount' => 25,
|
||||
'paid_at' => '2026-10-20 00:00',
|
||||
'source_scope' => 'personal_permission',
|
||||
'synced_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::connection('business_mirror')
|
||||
->table('ding_process_payment_line_mirror')
|
||||
->insert([
|
||||
$this->paymentLine($businessId, 1, 10, '2026-10-20', 1),
|
||||
$this->paymentLine($businessId, 2, 15, '2026-10-20', 1),
|
||||
]);
|
||||
|
||||
$data = $this->pageData->details(Request::create(
|
||||
'/1/workspace/financial-operations/management-expenses/details',
|
||||
@ -168,6 +188,42 @@ class ManagementExpensesPageDataTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_cross_month_payments_split_the_same_detail_line_by_paid_amount(): void
|
||||
{
|
||||
$businessId = '202609151200000000010';
|
||||
DB::connection('business_mirror')
|
||||
->table('ding_process_detail_mirror')
|
||||
->insert($this->row(
|
||||
$businessId,
|
||||
'5602.04',
|
||||
1,
|
||||
100,
|
||||
'审批中',
|
||||
'pending_sync',
|
||||
'present',
|
||||
'2026-09-15 10:00:00',
|
||||
null,
|
||||
));
|
||||
DB::connection('business_mirror')
|
||||
->table('ding_process_payment_line_mirror')
|
||||
->insert([
|
||||
$this->paymentLine($businessId, 1, 40, '2026-09-28', 1),
|
||||
$this->paymentLine($businessId, 1, 60, '2026-10-02', 2),
|
||||
]);
|
||||
|
||||
foreach (['2026-09' => 40.0, '2026-10' => 60.0] as $month => $amount) {
|
||||
$data = $this->pageData->details(Request::create(
|
||||
'/1/workspace/financial-operations/management-expenses/details',
|
||||
'GET',
|
||||
['business_id' => $businessId, 'paid_month' => $month],
|
||||
));
|
||||
|
||||
$this->assertSame(1, $data['pagination']['total']);
|
||||
$this->assertSame($amount, $data['summary']['amount_total']);
|
||||
$this->assertSame($amount, $data['rows'][0]['amount']);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_workbook_mapping_snapshot_is_complete_and_unique(): void
|
||||
{
|
||||
$mapping = json_decode(
|
||||
@ -244,6 +300,37 @@ class ManagementExpensesPageDataTest extends TestCase
|
||||
$table->decimal('amount', 18, 2)->nullable();
|
||||
$table->string('paid_at', 50)->nullable();
|
||||
$table->dateTime('approval_finished_at')->nullable();
|
||||
$table->string('source_scope', 40)->default('personal_permission');
|
||||
$table->timestamps();
|
||||
},
|
||||
);
|
||||
Schema::connection('business_mirror')->create(
|
||||
'ding_process_payment_mirror',
|
||||
function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('business_id', 32);
|
||||
$table->string('process_instance_id', 100);
|
||||
$table->unsignedInteger('payment_no');
|
||||
$table->string('sequence_spec', 255)->nullable();
|
||||
$table->decimal('amount', 18, 2);
|
||||
$table->string('paid_at', 50)->nullable();
|
||||
$table->string('source_scope', 40)->default('personal_permission');
|
||||
$table->dateTime('synced_at', 6);
|
||||
$table->timestamps();
|
||||
},
|
||||
);
|
||||
Schema::connection('business_mirror')->create(
|
||||
'ding_process_payment_line_mirror',
|
||||
function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('source_scope', 40)->default('personal_permission');
|
||||
$table->string('business_id', 32);
|
||||
$table->string('process_instance_id', 100)->nullable();
|
||||
$table->unsignedInteger('payment_no');
|
||||
$table->unsignedInteger('line_no');
|
||||
$table->string('paid_month', 7)->default('');
|
||||
$table->string('paid_at', 50)->nullable();
|
||||
$table->decimal('amount', 18, 2);
|
||||
$table->timestamps();
|
||||
},
|
||||
);
|
||||
@ -287,6 +374,22 @@ class ManagementExpensesPageDataTest extends TestCase
|
||||
$this->row('202609251200000000008', '5602.04', 1, 20, '已完成', 'completed', 'present', '2026-09-25 10:00:00', '2026-10-20'),
|
||||
$this->row('202610281200000000009', '5602.04', 1, 30, '已完成', 'completed', 'present', '2026-10-28 10:00:00', '2026-09-30'),
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ding_process_payment_mirror')->insert([
|
||||
$this->payment('202610011200000000001', 100, '2026-10-10 00:00'),
|
||||
$this->payment('202610021200000000002', 200, '2026-10-11 00:00'),
|
||||
$this->payment('202610031200000000003', 300, '2026-10-12 00:00'),
|
||||
$this->payment('202610051200000000005', 50, '2026-10-14 00:00'),
|
||||
$this->payment('202609251200000000008', 20, '2026-10-20 00:00'),
|
||||
$this->payment('202610281200000000009', 30, '2026-09-30 00:00'),
|
||||
]);
|
||||
DB::connection('business_mirror')->table('ding_process_payment_line_mirror')->insert([
|
||||
$this->paymentLine('202610011200000000001', 1, 100, '2026-10-10', 1),
|
||||
$this->paymentLine('202610021200000000002', 1, 200, '2026-10-11', 1),
|
||||
$this->paymentLine('202610031200000000003', 1, 300, '2026-10-12', 1),
|
||||
$this->paymentLine('202610051200000000005', 1, 50, '2026-10-14', 1),
|
||||
$this->paymentLine('202609251200000000008', 1, 20, '2026-10-20', 1),
|
||||
$this->paymentLine('202610281200000000009', 1, 30, '2026-09-30', 1),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
@ -320,4 +423,43 @@ class ManagementExpensesPageDataTest extends TestCase
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function payment(string $businessId, float $amount, string $paidAt): array
|
||||
{
|
||||
return [
|
||||
'business_id' => $businessId,
|
||||
'process_instance_id' => 'process-' . $businessId,
|
||||
'payment_no' => 1,
|
||||
'sequence_spec' => '1',
|
||||
'amount' => $amount,
|
||||
'paid_at' => $paidAt,
|
||||
'source_scope' => 'personal_permission',
|
||||
'synced_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function paymentLine(
|
||||
string $businessId,
|
||||
int $lineNo,
|
||||
float $amount,
|
||||
string $paidAt,
|
||||
int $paymentNo,
|
||||
): array {
|
||||
return [
|
||||
'source_scope' => 'personal_permission',
|
||||
'business_id' => $businessId,
|
||||
'process_instance_id' => 'process-' . $businessId,
|
||||
'payment_no' => $paymentNo,
|
||||
'line_no' => $lineNo,
|
||||
'paid_month' => substr($paidAt, 0, 7),
|
||||
'paid_at' => $paidAt,
|
||||
'amount' => $amount,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user