Files
boursecu_server/app/Services/MontzarReconcileService.php
T

659 lines
24 KiB
PHP
Raw Normal View History

2026-08-03 18:44:27 +03:30
<?php
namespace App\Services;
use App\Models\Order;
use App\Models\TransactionsAdmin;
use Carbon\Carbon;
use Hekmatinasser\Verta\Verta;
use Illuminate\Support\Facades\Schema;
/**
2026-08-03 19:42:23 +03:30
* حساب پوشش همکار برای خرید/فروش آبشدهٔ کیف اصلی — اول از روی سفارش‌های اپ،
* و در صورت دسترسی، مقایسهٔ اختیاری با دفتر زندهٔ نود Montzar.
2026-08-03 18:44:27 +03:30
* ربطی به ته‌حساب / زرین / پرگار ندارد.
*/
class MontzarReconcileService
{
private const GOLD_EPS = 0.002;
private const RIAL_EPS = 5000;
public function __construct(
private MontzarService $montzar,
private ConfigService $config
) {
}
/**
* @return array<string, mixed>
*/
public function reconcile(string $startDate, string $endDate): array
{
$start = Carbon::parse($startDate)->startOfDay();
$end = Carbon::parse($endDate)->endOfDay();
2026-08-03 19:42:23 +03:30
$localOrders = $this->loadMainWalletMeltedOrders($start, $end);
2026-08-03 18:44:27 +03:30
$localGifts = $this->loadLocalGifts($start, $end);
2026-08-03 19:42:23 +03:30
$covered = [];
$uncovered = [];
foreach ($localOrders as $order) {
if (!empty($order['covered'])) {
$covered[] = $order;
} else {
$uncovered[] = $order;
2026-08-03 18:44:27 +03:30
}
}
2026-08-03 19:42:23 +03:30
$localAll = $this->sumLocalOrders($localOrders, 'cash');
$localCovered = $this->sumLocalOrders($covered, 'montzar_cash');
$localUncovered = $this->sumLocalOrders($uncovered, 'cash');
$diffs = [];
foreach ($uncovered as $order) {
2026-08-03 18:44:27 +03:30
$diffs[] = [
2026-08-03 19:42:23 +03:30
'code' => 'local_uncovered',
2026-08-03 22:21:07 +03:30
'reason' => 'خرید یا فروش آبشده در اپ ثبت شده ولی نزد همکار ثبت نشده است.',
2026-08-03 18:44:27 +03:30
'colleague' => null,
'local' => $order,
];
}
2026-08-03 19:42:23 +03:30
$mandeh = null;
$classified = [];
$matchedPairs = [];
$colleagueError = null;
$colleagueSite = $this->emptySiteSum();
2026-08-03 18:44:27 +03:30
$lastRemain = null;
2026-08-03 19:42:23 +03:30
$colleagueOther = [];
$diffSiteGold = 0.0;
$diffSiteRial = 0;
try {
$mandehRaw = $this->montzar->fetchColleagueLedgerMandeh();
$mandeh = $this->normalizeMandeh($mandehRaw);
$colleagueRows = $this->montzar->fetchColleagueLedgerTable(
$start->toDateString(),
$end->toDateString()
);
foreach ($colleagueRows as $index => $row) {
if (!is_array($row)) {
continue;
}
$classified[] = $this->classifyColleagueRow($row, $index);
}
[$matchedPairs, , $usedOrderIds, $usedGiftIds] = $this->matchSiteRows(
$classified,
$covered,
$localGifts
);
foreach ($classified as $row) {
if (($row['kind'] ?? '') === 'site_buy' || ($row['kind'] ?? '') === 'site_sell') {
if (!empty($row['_matched'])) {
continue;
}
$diffs[] = [
'code' => 'colleague_site_only',
2026-08-03 22:21:07 +03:30
'reason' => 'سند «از سایت» در دفتر همکار هست ولی سفارش متناظر در اپ پیدا نشد.',
2026-08-03 19:42:23 +03:30
'colleague' => $this->publicColleagueRow($row),
'local' => null,
];
}
}
foreach ($covered as $order) {
if (isset($usedOrderIds[(int) $order['id']])) {
continue;
}
$diffs[] = [
'code' => 'local_covered_not_in_ledger',
2026-08-03 22:21:07 +03:30
'reason' => 'سفارش در اپ نزد همکار ثبت شده ولی در ریز دفتر همکار این بازه پیدا نشد.',
2026-08-03 19:42:23 +03:30
'colleague' => null,
'local' => $order,
];
}
foreach ($localGifts as $gift) {
if (isset($usedGiftIds[(int) $gift['id']])) {
continue;
}
$diffs[] = [
'code' => 'gift_uncovered',
2026-08-03 22:21:07 +03:30
'reason' => 'پاداش در اپ ثبت شده؛ سند متناظر در دفتر همکار این بازه پیدا نشد.',
2026-08-03 19:42:23 +03:30
'colleague' => null,
'local' => $gift,
];
}
foreach ($classified as $row) {
if (!empty($row['_matched'])) {
continue;
}
if (in_array($row['kind'], ['settlement', 'physical', 'gift', 'other'], true)) {
$colleagueOther[] = [
'code' => $this->diffCodeForKind($row['kind']),
'reason' => $this->reasonForKind($row['kind'], $row['desc'] ?? ''),
'colleague' => $this->publicColleagueRow($row),
'local' => null,
];
}
}
$colleagueSite = $this->sumColleagueSite($classified);
if ($classified !== []) {
$last = $classified[array_key_last($classified)];
$lastRemain = [
'gold' => (float) ($last['remain_gold'] ?? 0),
'rial' => (float) ($last['remain_rial'] ?? 0),
];
}
// مقایسهٔ ریال فقط با مبلغ منتظر (نه قیمت مشتری با اسپرد/کارمزد)
$diffSiteGold = round(
($colleagueSite['net_gold'] ?? 0) - ($localCovered['net_gold'] ?? 0),
3
);
$diffSiteRial = (int) round(
($colleagueSite['net_rial'] ?? 0) - ($localCovered['net_rial'] ?? 0)
);
} catch (\Throwable $e) {
report($e);
$colleagueError = $e->getMessage();
2026-08-03 18:44:27 +03:30
}
return [
'mandeh' => $mandeh,
2026-08-03 19:42:23 +03:30
'colleague_available' => $mandeh !== null && $colleagueError === null,
'colleague_error' => $colleagueError,
2026-08-03 18:44:27 +03:30
'last_table_remain' => $lastRemain,
'summary' => [
2026-08-03 19:42:23 +03:30
'local_all' => $localAll,
'local_covered' => $localCovered,
'local_uncovered' => $localUncovered,
'local_matched' => $localCovered,
2026-08-03 18:44:27 +03:30
'colleague_site' => $colleagueSite,
2026-08-03 19:42:23 +03:30
'diff_site_gold' => $diffSiteGold,
'diff_site_rial' => $diffSiteRial,
'order_count' => count($localOrders),
'covered_count' => count($covered),
'uncovered_count' => count($uncovered),
2026-08-03 18:44:27 +03:30
'matched_count' => count($matchedPairs),
'diff_count' => count($diffs),
2026-08-03 19:42:23 +03:30
'gift_count' => count($localGifts),
2026-08-03 18:44:27 +03:30
'colleague_row_count' => count($classified),
'local_order_count' => count($localOrders),
],
'colleague_rows' => array_map(fn ($r) => $this->publicColleagueRow($r), $classified),
'local_orders' => array_values($localOrders),
2026-08-03 19:42:23 +03:30
'local_uncovered_orders' => array_values($uncovered),
2026-08-03 18:44:27 +03:30
'local_gifts' => array_values($localGifts),
'matched' => $matchedPairs,
'diffs' => $diffs,
2026-08-03 19:42:23 +03:30
'colleague_other' => $colleagueOther,
2026-08-03 18:44:27 +03:30
'range' => [
'start' => $start->toDateTimeString(),
'end' => $end->toDateTimeString(),
],
];
}
2026-08-03 19:42:23 +03:30
/**
* خرید/فروش موفق آبشده روی کیف اصلی (main_wallet_products / main_product).
*
* @return list<array<string, mixed>>
*/
private function loadMainWalletMeltedOrders(Carbon $start, Carbon $end): array
{
$productIds = $this->mainMeltedProductIds();
$query = Order::query()
->with(['customer:id,name,mobile', 'product:id,title,group_type'])
->where('sts', 2)
->whereBetween('created_at', [$start, $end])
->orderBy('id');
if (Schema::hasColumn('orders', 'product_id')) {
$query->where(function ($q) use ($productIds) {
if ($productIds !== []) {
$q->whereIn('product_id', $productIds);
}
$q->orWhereNull('product_id');
$q->orWhereHas('product', function ($pq) {
$pq->where('group_type', 'آبشده');
});
});
}
$hasProvider = Schema::hasColumn('orders', 'provider_id');
$hasMontzarPrice = Schema::hasColumn('orders', 'total_price_montzar');
return $query->get()->map(function (Order $order) use ($hasProvider, $hasMontzarPrice) {
$direction = (int) $order->direction;
$amount = (float) $order->amount;
$customerCash = (float) ($order->total_price ?? $order->payment_amount ?? 0);
$montzarCash = $hasMontzarPrice ? (float) ($order->total_price_montzar ?? 0) : 0.0;
$providerId = $hasProvider ? ($order->provider_id ?? null) : null;
$covered = ($providerId !== null && $providerId !== '')
|| ($hasMontzarPrice && $order->total_price_montzar !== null);
return [
'id' => (int) $order->id,
'type' => 'order',
'direction' => $direction,
'direction_label' => Order::$DIRECTION[$direction] ?? (string) $direction,
'amount' => round($amount, 3),
'cash' => (int) round($customerCash),
'montzar_cash' => (int) round($montzarCash),
'covered' => $covered,
'provider_id' => $providerId,
'product_id' => $order->product_id,
'product_title' => $order->product->title ?? null,
'customer_id' => (int) $order->customer_id,
'customer_name' => $order->customer->name ?? null,
'created_at' => optional($order->created_at)?->toDateTimeString(),
'date_ymd' => optional($order->created_at)?->toDateString(),
];
})->all();
}
/**
* @return list<int>
*/
private function mainMeltedProductIds(): array
{
$ids = $this->config->mainWalletProductIds();
$main = $this->config->mainProductId();
if ($main) {
$ids[] = (int) $main;
}
return array_values(array_unique(array_filter($ids)));
}
2026-08-03 18:44:27 +03:30
/**
* @param list<array<string, mixed>> $raw
2026-08-03 19:42:23 +03:30
* @return array{gold: float, cash: float, gold_abs: float, cash_abs: float, gold_state: string, rial_state: string, gold_unit: string, cash_unit: string, items: list<array<string, mixed>>}
2026-08-03 18:44:27 +03:30
*/
private function normalizeMandeh(array $raw): array
{
$gold = 0.0;
$cash = 0.0;
$goldUnit = 'گرم';
$cashUnit = 'ریال';
foreach ($raw as $item) {
if (!is_array($item)) {
continue;
}
$eng = strtolower((string) ($item['name_eng'] ?? ''));
$value = (float) ($item['value'] ?? 0);
if ($eng === 'gold' || ($item['name_fa'] ?? '') === 'طلا') {
$gold = $value;
$goldUnit = (string) ($item['unit'] ?? 'گرم');
}
if ($eng === 'cash' || ($item['name_fa'] ?? '') === 'ریال') {
$cash = $value;
$cashUnit = (string) ($item['unit'] ?? 'ریال');
}
}
return [
'gold' => $gold,
'cash' => $cash,
'gold_abs' => abs($gold),
'cash_abs' => abs($cash),
'gold_state' => $gold < 0 ? 'بد' : ($gold > 0 ? 'بس' : 'صفر'),
'rial_state' => $cash < 0 ? 'بد' : ($cash > 0 ? 'بس' : 'صفر'),
'gold_unit' => $goldUnit,
'cash_unit' => $cashUnit,
'items' => $raw,
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function classifyColleagueRow(array $row, int $index): array
{
$desc = trim((string) ($row['desc'] ?? ''));
$bedGold = $this->num($row['bedGold'] ?? 0);
$besGold = $this->num($row['besGold'] ?? 0);
$bedRial = $this->num($row['bedRial'] ?? 0);
$besRial = $this->num($row['besRial'] ?? 0);
$kind = 'other';
2026-08-03 19:42:23 +03:30
$siteDirection = null;
2026-08-03 18:44:27 +03:30
$fromSite = str_contains($desc, 'سايت') || str_contains($desc, 'سایت');
if ($fromSite && (str_contains($desc, 'فروش') || str_starts_with($desc, 'فروش'))) {
$kind = 'site_sell';
$siteDirection = 1;
} elseif ($fromSite && (str_contains($desc, 'خريد') || str_contains($desc, 'خرید'))) {
$kind = 'site_buy';
$siteDirection = 2;
} elseif ($desc === '') {
$kind = 'settlement';
} elseif (str_contains($desc, 'پرداخت')) {
$kind = 'physical';
} elseif (str_contains($desc, 'پاداش') || str_contains($desc, 'ثبت نام') || str_contains($desc, 'معرفی')) {
$kind = 'gift';
}
return [
'index' => $index,
'id' => $row['id'] ?? null,
'doc_id' => $row['docId'] ?? null,
'date' => $row['date'] ?? null,
2026-08-03 19:42:23 +03:30
'date_ymd' => $this->colleagueDateToYmd((string) ($row['date'] ?? '')),
2026-08-03 18:44:27 +03:30
'desc' => $desc,
'kind' => $kind,
'site_direction' => $siteDirection,
'bed_gold' => $bedGold,
'bes_gold' => $besGold,
'bed_rial' => $bedRial,
'bes_rial' => $besRial,
'net_gold' => round($besGold - $bedGold, 3),
'net_rial' => round($besRial - $bedRial, 0),
'remain_gold' => $this->num($row['remainGold'] ?? 0),
'remain_rial' => $this->num($row['remainRial'] ?? 0),
'gold_state' => $row['goldState'] ?? null,
'rial_state' => $row['rialState'] ?? null,
'_matched' => false,
];
}
/**
* @return list<array<string, mixed>>
*/
private function loadLocalGifts(Carbon $start, Carbon $end): array
{
return TransactionsAdmin::query()
->whereBetween('created_at', [$start, $end])
->whereIn('note', ['پاداش بابت معرفی', 'پاداش بابت ثبت نام'])
->where('gold', '>', 0)
->orderBy('id')
->get()
->map(function (TransactionsAdmin $row) {
return [
'id' => (int) $row->id,
'type' => 'gift',
'direction' => 1,
2026-08-03 22:21:07 +03:30
'direction_label' => 'خرید پاداش از همکار',
2026-08-03 18:44:27 +03:30
'amount' => round((float) $row->gold, 3),
'cash' => 0,
2026-08-03 19:42:23 +03:30
'montzar_cash' => 0,
'covered' => false,
2026-08-03 18:44:27 +03:30
'note' => $row->note,
'customer_id' => (int) ($row->customer_to_id ?: $row->customer_id),
'created_at' => optional($row->created_at)?->toDateTimeString(),
'date_ymd' => optional($row->created_at)?->toDateString(),
];
})
->all();
}
/**
* @param list<array<string, mixed>> $classified
* @param list<array<string, mixed>> $localOrders
* @param list<array<string, mixed>> $localGifts
* @return array{0: list<array<string, mixed>>, 1: list<array<string, mixed>>, 2: array<int, true>, 3: array<int, true>}
*/
private function matchSiteRows(array &$classified, array $localOrders, array $localGifts): array
{
$matchedPairs = [];
$diffs = [];
$usedOrders = [];
$usedGifts = [];
$ordersByDirDate = [];
foreach ($localOrders as $order) {
$key = ((int) $order['direction']) . '|' . ($order['date_ymd'] ?? '');
$ordersByDirDate[$key][] = $order;
}
$giftsByDate = [];
foreach ($localGifts as $gift) {
$giftsByDate[$gift['date_ymd'] ?? ''][] = $gift;
}
foreach ($classified as &$row) {
if (($row['kind'] ?? '') === 'gift') {
$day = $row['date_ymd'] ?? '';
$candidates = $giftsByDate[$day] ?? [];
$found = null;
foreach ($candidates as $gift) {
if (isset($usedGifts[(int) $gift['id']])) {
continue;
}
if ($this->goldClose((float) $gift['amount'], abs((float) $row['net_gold']))) {
$found = $gift;
break;
}
}
if ($found) {
$usedGifts[(int) $found['id']] = true;
$row['_matched'] = true;
$matchedPairs[] = [
'code' => 'matched',
'colleague' => $this->publicColleagueRow($row),
'local' => $found,
];
}
continue;
}
if (($row['kind'] ?? '') !== 'site_buy' && ($row['kind'] ?? '') !== 'site_sell') {
continue;
}
$dir = (int) ($row['site_direction'] ?? 0);
$day = $row['date_ymd'] ?? '';
$key = $dir . '|' . $day;
$candidates = $ordersByDirDate[$key] ?? [];
$targetGold = abs((float) $row['net_gold']);
$targetRial = abs((float) $row['net_rial']);
$found = null;
foreach ($candidates as $order) {
if (isset($usedOrders[(int) $order['id']])) {
continue;
}
2026-08-03 19:42:23 +03:30
$orderRial = (float) (($order['montzar_cash'] ?? 0) > 0
? $order['montzar_cash']
: $order['cash']);
2026-08-03 18:44:27 +03:30
if (
$this->goldClose((float) $order['amount'], $targetGold)
2026-08-03 19:42:23 +03:30
&& $this->rialClose($orderRial, $targetRial)
2026-08-03 18:44:27 +03:30
) {
$found = $order;
break;
}
}
if (!$found) {
foreach ($candidates as $order) {
if (isset($usedOrders[(int) $order['id']])) {
continue;
}
if ($this->goldClose((float) $order['amount'], $targetGold)) {
$found = $order;
break;
}
}
}
if ($found) {
$usedOrders[(int) $found['id']] = true;
$row['_matched'] = true;
$matchedPairs[] = [
'code' => 'matched',
'colleague' => $this->publicColleagueRow($row),
'local' => $found,
];
}
}
unset($row);
return [$matchedPairs, $diffs, $usedOrders, $usedGifts];
}
2026-08-03 19:42:23 +03:30
/**
* @return array{buy_gold: float, sell_gold: float, buy_rial: float, sell_rial: float, net_gold: float, net_rial: float}
*/
private function emptySiteSum(): array
{
return [
'buy_gold' => 0.0,
'sell_gold' => 0.0,
'buy_rial' => 0,
'sell_rial' => 0,
'net_gold' => 0.0,
'net_rial' => 0,
];
}
2026-08-03 18:44:27 +03:30
/**
* @param list<array<string, mixed>> $classified
* @return array{buy_gold: float, sell_gold: float, buy_rial: float, sell_rial: float, net_gold: float, net_rial: float}
*/
private function sumColleagueSite(array $classified): array
{
2026-08-03 19:42:23 +03:30
$buyGold = 0.0;
2026-08-03 18:44:27 +03:30
$sellGold = 0.0;
$buyRial = 0.0;
$sellRial = 0.0;
foreach ($classified as $row) {
if (($row['kind'] ?? '') === 'site_sell') {
$buyGold += abs((float) $row['net_gold']);
$buyRial += abs((float) $row['net_rial']);
}
if (($row['kind'] ?? '') === 'site_buy') {
$sellGold += abs((float) $row['net_gold']);
$sellRial += abs((float) $row['net_rial']);
}
}
return [
'buy_gold' => round($buyGold, 3),
'sell_gold' => round($sellGold, 3),
'buy_rial' => (int) round($buyRial),
'sell_rial' => (int) round($sellRial),
'net_gold' => round($buyGold - $sellGold, 3),
'net_rial' => (int) round($sellRial - $buyRial),
];
}
/**
* @param list<array<string, mixed>> $orders
2026-08-03 19:42:23 +03:30
* @param 'cash'|'montzar_cash' $rialField
2026-08-03 18:44:27 +03:30
* @return array{buy_gold: float, sell_gold: float, buy_rial: float, sell_rial: float, net_gold: float, net_rial: float}
*/
2026-08-03 19:42:23 +03:30
private function sumLocalOrders(array $orders, string $rialField = 'cash'): array
2026-08-03 18:44:27 +03:30
{
$buyGold = 0.0;
$sellGold = 0.0;
$buyRial = 0.0;
$sellRial = 0.0;
foreach ($orders as $order) {
2026-08-03 19:42:23 +03:30
$rial = (float) ($order[$rialField] ?? 0);
if ($rialField === 'montzar_cash' && $rial <= 0) {
$rial = (float) ($order['cash'] ?? 0);
}
2026-08-03 18:44:27 +03:30
if ((int) $order['direction'] === 1) {
$buyGold += (float) $order['amount'];
2026-08-03 19:42:23 +03:30
$buyRial += $rial;
2026-08-03 18:44:27 +03:30
} else {
$sellGold += (float) $order['amount'];
2026-08-03 19:42:23 +03:30
$sellRial += $rial;
2026-08-03 18:44:27 +03:30
}
}
return [
'buy_gold' => round($buyGold, 3),
'sell_gold' => round($sellGold, 3),
'buy_rial' => (int) round($buyRial),
'sell_rial' => (int) round($sellRial),
'net_gold' => round($buyGold - $sellGold, 3),
'net_rial' => (int) round($sellRial - $buyRial),
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function publicColleagueRow(array $row): array
{
unset($row['_matched']);
return $row;
}
private function diffCodeForKind(string $kind): string
{
return match ($kind) {
'settlement' => 'colleague_settlement',
'physical' => 'colleague_physical',
'gift' => 'gift_cover',
default => 'other',
};
}
private function reasonForKind(string $kind, string $desc): string
{
return match ($kind) {
2026-08-03 19:42:23 +03:30
'settlement' => 'واریز/برداشت یا تعدیل روی دفتر همکار — معمولاً تسویه است و سفارش اپ نیست.',
'physical' => 'پرداخت فیزیکی روی دفتر همکار.',
2026-08-03 18:44:27 +03:30
'gift' => 'سند مرتبط با پاداش روی دفتر همکار.',
default => $desc !== ''
? 'سند سایر روی دفتر همکار: ' . $desc
: 'سند سایر روی دفتر همکار.',
};
}
private function colleagueDateToYmd(string $date): ?string
{
$date = trim($date);
if ($date === '') {
return null;
}
if (preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $date, $m) && (int) $m[1] >= 1300 && (int) $m[1] < 1600) {
try {
return Verta::createJalaliDate((int) $m[1], (int) $m[2], (int) $m[3])
->toCarbon()
->toDateString();
} catch (\Throwable $e) {
return null;
}
}
try {
return Carbon::parse($date)->toDateString();
} catch (\Throwable $e) {
return null;
}
}
private function num(mixed $v): float
{
return (float) str_replace(',', '', (string) ($v ?? 0));
}
private function goldClose(float $a, float $b): bool
{
return abs($a - $b) <= self::GOLD_EPS;
}
private function rialClose(float $a, float $b): bool
{
return abs($a - $b) <= self::RIAL_EPS;
}
}