,alerts:array>,created:int,updated:int} */ public function syncLatest(int $companyId): array { $latest = $this->shopBalances->latestBalanceDate(now()); if ($latest === null) { return [ 'balance_date' => '', 'dates' => [], 'alerts' => [], 'created' => 0, 'updated' => 0, ]; } return $this->syncForDate($companyId, $latest); } /** * @return array{balance_date:string,dates:list,alerts:array>,created:int,updated:int} */ public function syncForDate(int $companyId, string $balanceDate): array { $dates = self::datesEndingAt($balanceDate); $stores = StoreReceivingAccount::withoutGlobalScopes() ->where('company_id', $companyId) ->whereNull('deleted_at') ->orderBy('id') ->get(); if ($stores->isEmpty()) { return [ 'balance_date' => $balanceDate, 'dates' => $dates, 'alerts' => [], 'created' => 0, 'updated' => 0, ]; } $balancesByDate = $this->shopBalances->matchesForStoresOnDates($stores, $dates); $reserveStandards = $stores->mapWithKeys( static fn (StoreReceivingAccount $store): array => [ (int) $store->id => WithdrawalReserveStandard::amountFor( $store->store_platform, $store->erp_store_name, ), ], )->all(); $alerts = self::detect($stores, $balancesByDate, $reserveStandards, $dates); [$created, $updated] = DB::transaction(function () use ($companyId, $stores, &$alerts): array { $created = 0; $updated = 0; foreach ($stores as $store) { $alert = $alerts[(int) $store->id] ?? null; if ($alert === null) { continue; } $title = $this->todoTitle($store); $description = $this->description($store, $alert); $todo = Todo::withoutGlobalScopes() ->where('company_id', $companyId) ->where('category', self::CATEGORY) ->where('title', $title) ->whereNull('deleted_at') ->whereNotIn('status', ['已完成', '已关闭']) ->orderByDesc('id') ->lockForUpdate() ->first(); if ($todo === null) { $todo = Todo::withoutGlobalScopes()->create([ 'company_id' => $companyId, 'title' => $title, 'category' => self::CATEGORY, 'priority' => '高', 'status' => self::TODO_STATUS, 'description' => $description, ]); $created++; } else { $attributes = [ 'priority' => '高', 'description' => $description, ]; if ($todo->status !== self::TODO_STATUS) { $attributes['status'] = self::TODO_STATUS; } if ($todo->fill($attributes)->isDirty()) { $todo->save(); $updated++; } } $alerts[(int) $store->id]['todo_id'] = (int) $todo->id; } return [$created, $updated]; }); return [ 'balance_date' => $balanceDate, 'dates' => $dates, 'alerts' => $alerts, 'created' => $created, 'updated' => $updated, ]; } /** * @param iterable $stores * @param array> $balancesByDate * @param array $reserveStandards * @param list $dates * @return array */ public static function detect( iterable $stores, array $balancesByDate, array $reserveStandards, array $dates, ): array { if (count($dates) !== 3) { return []; } $alerts = []; foreach ($stores as $store) { $reserve = $reserveStandards[(int) $store->id] ?? null; if ($reserve === null) { continue; } $balanceCents = null; $unchanged = true; foreach ($dates as $date) { $match = $balancesByDate[(int) $store->id][$date] ?? ['status' => 'not_ready', 'balance' => null]; if (($match['status'] ?? '') !== 'matched' || ($match['balance'] ?? null) === null) { $unchanged = false; break; } $cents = (int) round((float) $match['balance'] * 100); if ($balanceCents === null) { $balanceCents = $cents; } elseif ($balanceCents !== $cents) { $unchanged = false; break; } } if (! $unchanged || $balanceCents === null || $balanceCents <= $reserve * 100) { continue; } $balance = $balanceCents / 100; $alerts[(int) $store->id] = [ 'balance' => $balance, 'start_date' => $dates[0], 'end_date' => $dates[2], 'message' => sprintf( '余额连续 3 天保持 %s 元,高于预留标准 %s 元。', number_format($balance, 2), number_format($reserve, 2), ), ]; } return $alerts; } /** @return list */ public static function datesEndingAt(string $balanceDate): array { $end = CarbonImmutable::createFromFormat('!Y-m-d', $balanceDate); if (! $end instanceof CarbonImmutable || $end->format('Y-m-d') !== $balanceDate) { return []; } return [ $end->subDays(2)->toDateString(), $end->subDay()->toDateString(), $end->toDateString(), ]; } private function todoTitle(StoreReceivingAccount $store): string { return mb_substr( '提现余额连续3天未变动|' . (string) $store->store_platform . '|' . (string) $store->erp_store_name, 0, 255, ); } /** @param array{balance:float,start_date:string,end_date:string,message:string} $alert */ private function description(StoreReceivingAccount $store, array $alert): string { return implode("\n", array_filter([ $alert['message'], '统计期间:' . $alert['start_date'] . ' 至 ' . $alert['end_date'], '店铺平台:' . ($store->store_platform ?: '未配置'), 'ERP店铺名称:' . ($store->erp_store_name ?: '未配置'), '店铺主体:' . ($store->store_entity_name ?: '未配置'), '平台店铺ID:' . ($store->store_id ?: '未配置'), '处理建议:核实店铺余额是否真实停滞,并检查是否应执行提现。', ], static fn (string $line): bool => $line !== '')); } }