ArcWOC/includes/class-hesabix-v2-invoice-profit-service.php
morrning 42fbedcf26 Fix 4.9.2: show invoice profit in Hesabix currency (IRR)
Profit amounts are in Hesabix invoice currency; formatting with WC
order currency (IRT/Toman) mislabeled rials as tomans.
2026-08-04 13:16:01 +00:00

651 lines
22 KiB
PHP

<?php
/**
* سود فاکتور حسابیکس روی سفارش ووکامرس.
*
* پس از همگام‌سازی موفق، جزئیات فاکتور از API خوانده و در متای سفارش ذخیره می‌شود.
*
* @package Hesabix_V2
* @subpackage Hesabix_V2/includes
* @since 4.9.0
*/
if (!defined('WPINC')) {
die;
}
class Hesabix_V2_Invoice_Profit_Service
{
/** @var string سود نهایی (معادل total_profit در لیست فاکتور حسابیکس) */
const META_PROFIT = '_hesabix_v2_invoice_profit';
/** @var string درصد سود نهایی */
const META_PROFIT_PERCENT = '_hesabix_v2_invoice_profit_percent';
/** @var string سود ناخالص */
const META_GROSS = '_hesabix_v2_invoice_gross_profit';
/** @var string سود خالص */
const META_NET = '_hesabix_v2_invoice_net_profit';
/** @var string زمان به‌روزرسانی (unix) */
const META_AT = '_hesabix_v2_invoice_profit_at';
/** @var string شناسه فاکتوری که سود برایش کش شده */
const META_INVOICE_ID = '_hesabix_v2_invoice_profit_invoice_id';
/** @var string بهای تمام‌شده محاسبه‌شده (برای تشخیص سود ۱۰۰٪ بدون قیمت خرید) */
const META_TOTAL_COST = '_hesabix_v2_invoice_total_cost';
/** @var string جمع فروش مبنای سود */
const META_TOTAL_SALES = '_hesabix_v2_invoice_total_sales';
/** @var string کد ارز فاکتور حسابیکس (مثلاً IRR) */
const META_CURRENCY_CODE = '_hesabix_v2_invoice_profit_currency';
/** @var string عنوان ارز فاکتور */
const META_CURRENCY_TITLE = '_hesabix_v2_invoice_profit_currency_title';
/**
* خواندن سود ذخیره‌شده برای سفارش.
*
* @param int|WC_Order $order_or_id
* @return array{
* has_value:bool,
* profit:?float,
* profit_percent:?float,
* gross_profit:?float,
* net_profit:?float,
* refreshed_at:?int,
* invoice_id:?int
* }
*/
public static function get_for_order($order_or_id)
{
$order = self::resolve_order($order_or_id);
$empty = array(
'has_value' => false,
'profit' => null,
'profit_percent' => null,
'gross_profit' => null,
'net_profit' => null,
'total_cost' => null,
'total_sales' => null,
'currency_code' => null,
'currency_title' => null,
'refreshed_at' => null,
'invoice_id' => null,
'cost_missing' => false,
);
if (!$order) {
return $empty;
}
$raw = $order->get_meta(self::META_PROFIT, true);
if ($raw === '' || $raw === null) {
return $empty;
}
$at = $order->get_meta(self::META_AT, true);
$hid = $order->get_meta(self::META_INVOICE_ID, true);
$total_cost = self::meta_float_or_null($order, self::META_TOTAL_COST);
$total_sales = self::meta_float_or_null($order, self::META_TOTAL_SALES);
$ccode = $order->get_meta(self::META_CURRENCY_CODE, true);
$ctitle = $order->get_meta(self::META_CURRENCY_TITLE, true);
return array(
'has_value' => true,
'profit' => (float) $raw,
'profit_percent' => self::meta_float_or_null($order, self::META_PROFIT_PERCENT),
'gross_profit' => self::meta_float_or_null($order, self::META_GROSS),
'net_profit' => self::meta_float_or_null($order, self::META_NET),
'total_cost' => $total_cost,
'total_sales' => $total_sales,
'currency_code' => ($ccode !== '' && $ccode !== null) ? strtoupper((string) $ccode) : null,
'currency_title' => ($ctitle !== '' && $ctitle !== null) ? (string) $ctitle : null,
'refreshed_at' => ($at !== '' && $at !== null) ? (int) $at : null,
'invoice_id' => ($hid !== '' && $hid !== null) ? (int) $hid : null,
'cost_missing' => (
($total_cost !== null && (float) $total_cost <= 0.0 && (float) $raw > 0.0)
|| (
$total_cost === null
&& self::meta_float_or_null($order, self::META_PROFIT_PERCENT) !== null
&& (float) self::meta_float_or_null($order, self::META_PROFIT_PERCENT) >= 99.5
&& (float) $raw > 0.0
)
),
);
}
/**
* پاک‌سازی متای سود (مثلاً پس از لغو ارسال).
*
* @param int|WC_Order $order_or_id
* @return void
*/
public static function clear_for_order($order_or_id)
{
$order = self::resolve_order($order_or_id);
if (!$order) {
return;
}
foreach (array(
self::META_PROFIT,
self::META_PROFIT_PERCENT,
self::META_GROSS,
self::META_NET,
self::META_AT,
self::META_INVOICE_ID,
self::META_TOTAL_COST,
self::META_TOTAL_SALES,
self::META_CURRENCY_CODE,
self::META_CURRENCY_TITLE,
) as $key) {
$order->delete_meta_data($key);
}
if (doing_action('woocommerce_update_order')) {
$order->save_meta_data();
} else {
$order->save();
}
}
/**
* دریافت سود از API و ذخیره روی سفارش.
*
* @param int $order_id
* @param int|null $invoice_id اگر null باشد از نگاشت خوانده می‌شود
* @param Hesabix_V2_Api|null $api
* @return array{success:bool,message:string,data?:array}
*/
public static function refresh_for_order($order_id, $invoice_id = null, $api = null)
{
$order_id = (int) $order_id;
$order = wc_get_order($order_id);
if (!$order) {
return array(
'success' => false,
'message' => __('سفارش یافت نشد.', 'hesabix-v2'),
);
}
$hid = $invoice_id !== null ? absint($invoice_id) : 0;
if ($hid < 1) {
$db = new Hesabix_V2_DB_Service();
$map = $db->get_mapping('order', $order_id);
if ($map && !empty($map['hesabix_id'])) {
$hid = absint((string) $map['hesabix_id']);
}
}
if ($hid < 1) {
self::clear_for_order($order);
return array(
'success' => false,
'message' => __('این سفارش هنوز به فاکتور حسابیکس متصل نیست.', 'hesabix-v2'),
);
}
if (!$api) {
$api = new Hesabix_V2_Api();
}
$res = $api->get_invoice($hid);
if (empty($res['success']) || !is_array($res['data'] ?? null)) {
$msg = isset($res['message']) ? (string) $res['message'] : __('دریافت جزئیات فاکتور ناموفق بود.', 'hesabix-v2');
return array(
'success' => false,
'message' => $msg,
);
}
$parsed = self::extract_profit_fields($res['data']);
self::persist($order, $hid, $parsed);
return array(
'success' => true,
'message' => __('سود فاکتور به‌روز شد.', 'hesabix-v2'),
'data' => self::get_for_order($order),
);
}
/**
* پس از همگام‌سازی موفق فاکتور — خطا را می‌بلعد تا sync خراب نشود.
*
* @param int $order_id
* @param int $invoice_id
* @param Hesabix_V2_Api|null $api
* @return void
*/
public static function maybe_refresh_after_sync($order_id, $invoice_id, $api = null)
{
try {
$result = self::refresh_for_order((int) $order_id, (int) $invoice_id, $api);
if (empty($result['success']) && class_exists('Hesabix_V2_Log_Service')) {
Hesabix_V2_Log_Service::debug('Invoice profit refresh skipped/failed', array(
'entity_type' => 'order',
'entity_id' => (int) $order_id,
'hesabix_id' => (int) $invoice_id,
'message' => isset($result['message']) ? (string) $result['message'] : '',
));
}
} catch (Exception $e) {
if (class_exists('Hesabix_V2_Log_Service')) {
Hesabix_V2_Log_Service::debug('Invoice profit refresh exception', array(
'entity_type' => 'order',
'entity_id' => (int) $order_id,
'error' => $e->getMessage(),
));
}
}
}
/**
* استخراج فیلدهای سود از پاسخ GET فاکتور.
*
* پاسخ API معمولاً به شکل { success, data: { item: {...} } } است.
*
* @param array<string,mixed> $invoice
* @return array{profit:float,profit_percent:?float,gross_profit:?float,net_profit:?float}
*/
public static function extract_profit_fields(array $invoice)
{
// unwrap: data.item یا خود سند
if (isset($invoice['item']) && is_array($invoice['item'])) {
$invoice = $invoice['item'];
} elseif (isset($invoice['data']) && is_array($invoice['data'])) {
$inner = $invoice['data'];
if (isset($inner['item']) && is_array($inner['item'])) {
$invoice = $inner['item'];
} elseif (isset($inner['total_profit']) || isset($inner['gross_profit']) || isset($inner['id'])) {
$invoice = $inner;
}
}
$profit = null;
if (isset($invoice['total_profit']) && is_numeric($invoice['total_profit'])) {
$profit = (float) $invoice['total_profit'];
} elseif (isset($invoice['net_profit']) && is_numeric($invoice['net_profit'])) {
$profit = (float) $invoice['net_profit'];
} elseif (isset($invoice['gross_profit']) && is_numeric($invoice['gross_profit'])) {
$profit = (float) $invoice['gross_profit'];
} else {
$profit = 0.0;
}
$percent = null;
if (isset($invoice['total_profit_percent']) && is_numeric($invoice['total_profit_percent'])) {
$percent = (float) $invoice['total_profit_percent'];
} elseif (isset($invoice['net_profit_percent']) && is_numeric($invoice['net_profit_percent'])) {
$percent = (float) $invoice['net_profit_percent'];
} elseif (isset($invoice['gross_profit_percent']) && is_numeric($invoice['gross_profit_percent'])) {
$percent = (float) $invoice['gross_profit_percent'];
}
$gross = null;
if (isset($invoice['gross_profit']) && is_numeric($invoice['gross_profit'])) {
$gross = (float) $invoice['gross_profit'];
}
$net = null;
if (isset($invoice['net_profit']) && is_numeric($invoice['net_profit'])) {
$net = (float) $invoice['net_profit'];
}
// اگر فقط سود دفتر شناسایی‌شده موجود باشد
if ($profit == 0.0 && $gross === null && isset($invoice['recognized_profit_ledger']) && is_array($invoice['recognized_profit_ledger'])) {
$rp = $invoice['recognized_profit_ledger'];
if (isset($rp['gross_profit_recognized']) && is_numeric($rp['gross_profit_recognized'])) {
$profit = (float) $rp['gross_profit_recognized'];
$gross = $profit;
}
if (isset($rp['gross_profit_percent_recognized']) && is_numeric($rp['gross_profit_percent_recognized'])) {
$percent = (float) $rp['gross_profit_percent_recognized'];
}
}
return array(
'profit' => $profit,
'profit_percent' => $percent,
'gross_profit' => $gross,
'net_profit' => $net,
'total_cost' => isset($invoice['total_cost']) && is_numeric($invoice['total_cost'])
? (float) $invoice['total_cost']
: null,
'total_sales' => isset($invoice['total_sales']) && is_numeric($invoice['total_sales'])
? (float) $invoice['total_sales']
: null,
'currency_code' => isset($invoice['currency_code']) && is_string($invoice['currency_code'])
? strtoupper(trim($invoice['currency_code']))
: null,
'currency_title' => isset($invoice['currency_title']) && is_string($invoice['currency_title'])
? trim($invoice['currency_title'])
: (isset($invoice['currency_name']) && is_string($invoice['currency_name'])
? trim($invoice['currency_name'])
: null),
);
}
/**
* HTML فشرده برای ستون فهرست سفارش‌ها.
*
* @param int|WC_Order $order_or_id
* @return string
*/
public static function render_list_cell($order_or_id)
{
$order = self::resolve_order($order_or_id);
if (!$order) {
return '<span class="hesabix-v2-muted">—</span>';
}
$data = self::get_for_order($order);
$oid = (int) $order->get_id();
if (!$data['has_value']) {
$row = class_exists('Hesabix_V2_Invoice_Service')
? Hesabix_V2_Invoice_Service::get_sync_status($oid)
: null;
$synced = $row && !empty($row['hesabix_id']);
$hint = $synced
? __('هنوز دریافت نشده', 'hesabix-v2')
: __('—', 'hesabix-v2');
$html = '<span class="hesabix-v2-profit-empty">' . esc_html($hint) . '</span>';
if ($synced) {
$html .= sprintf(
'<br /><button type="button" class="button-link hesabix-v2-refresh-profit" data-order-id="%d">%s</button>',
$oid,
esc_html__('دریافت سود', 'hesabix-v2')
);
}
return $html;
}
$amount = self::format_money((float) $data['profit'], $order, $data);
$tone = ((float) $data['profit'] >= 0) ? 'positive' : 'negative';
$html = '<span class="hesabix-v2-profit-amount hesabix-v2-profit-' . esc_attr($tone) . '">'
. esc_html($amount)
. '</span>';
if ($data['profit_percent'] !== null) {
$html .= '<br /><span class="hesabix-v2-profit-pct">'
. esc_html(self::format_percent((float) $data['profit_percent']))
. '</span>';
}
$cc = isset($data['currency_code']) ? (string) $data['currency_code'] : '';
$wc = method_exists($order, 'get_currency') ? strtoupper((string) $order->get_currency()) : '';
if ($cc !== '' && $wc !== '' && $cc !== $wc) {
$html .= '<br /><span class="hesabix-v2-profit-curr-hint">'
. esc_html(
sprintf(
/* translators: %s: Hesabix currency code */
__('واحد حسابیکس: %s', 'hesabix-v2'),
$cc
)
)
. '</span>';
}
$html .= sprintf(
'<br /><button type="button" class="button-link hesabix-v2-refresh-profit" data-order-id="%d" title="%s">%s</button>',
$oid,
esc_attr__('به‌روزرسانی سود از حسابیکس', 'hesabix-v2'),
esc_html__('تازه‌سازی', 'hesabix-v2')
);
return $html;
}
/**
* بلوک جزئیات برای متاباکس سفارش.
*
* @param WC_Order $order
* @return string
*/
public static function render_meta_box_block(WC_Order $order)
{
$data = self::get_for_order($order);
$oid = (int) $order->get_id();
ob_start();
?>
<div class="hesabix-v2-profit-panel" data-order-id="<?php echo esc_attr((string) $oid); ?>">
<div class="hesabix-v2-profit-panel__head">
<strong><?php esc_html_e('سود فاکتور', 'hesabix-v2'); ?></strong>
<button type="button" class="button button-small hesabix-v2-refresh-profit" data-order-id="<?php echo esc_attr((string) $oid); ?>">
<?php esc_html_e('به‌روزرسانی', 'hesabix-v2'); ?>
</button>
</div>
<div class="hesabix-v2-profit-panel__body">
<?php if (!$data['has_value']) : ?>
<p class="hesabix-v2-profit-empty description">
<?php esc_html_e('پس از همگام‌سازی سفارش، سود از حسابیکس خوانده می‌شود. می‌توانید همین‌جا دستی به‌روز کنید.', 'hesabix-v2'); ?>
</p>
<?php else : ?>
<?php
$tone = ((float) $data['profit'] >= 0) ? 'positive' : 'negative';
?>
<p class="hesabix-v2-profit-hero hesabix-v2-profit-<?php echo esc_attr($tone); ?>">
<span class="hesabix-v2-profit-hero__label"><?php esc_html_e('سود نهایی', 'hesabix-v2'); ?></span>
<span class="hesabix-v2-profit-hero__value"><?php echo esc_html(self::format_money((float) $data['profit'], $order, $data)); ?></span>
<?php if ($data['profit_percent'] !== null) : ?>
<span class="hesabix-v2-profit-hero__pct"><?php echo esc_html(self::format_percent((float) $data['profit_percent'])); ?></span>
<?php endif; ?>
</p>
<?php
$cc = isset($data['currency_code']) ? (string) $data['currency_code'] : '';
$wc = strtoupper((string) $order->get_currency());
if ($cc !== '' && $wc !== '' && $cc !== $wc) :
?>
<p class="description hesabix-v2-profit-curr-hint" style="margin:4px 0 8px;">
<?php
echo esc_html(
sprintf(
/* translators: 1: Hesabix currency, 2: WooCommerce currency */
__('مبالغ سود به واحد حسابیکس (%1$s) است؛ مبلغ سفارش ووکامرس (%2$s) است.', 'hesabix-v2'),
$cc,
$wc
)
);
?>
</p>
<?php endif; ?>
<ul class="hesabix-v2-profit-details">
<?php if ($data['gross_profit'] !== null) : ?>
<li>
<span><?php esc_html_e('ناخالص', 'hesabix-v2'); ?></span>
<strong><?php echo esc_html(self::format_money((float) $data['gross_profit'], $order, $data)); ?></strong>
</li>
<?php endif; ?>
<?php if ($data['net_profit'] !== null) : ?>
<li>
<span><?php esc_html_e('خالص', 'hesabix-v2'); ?></span>
<strong><?php echo esc_html(self::format_money((float) $data['net_profit'], $order, $data)); ?></strong>
</li>
<?php endif; ?>
<?php if ($data['total_cost'] !== null) : ?>
<li>
<span><?php esc_html_e('بهای تمام‌شده', 'hesabix-v2'); ?></span>
<strong><?php echo esc_html(self::format_money((float) $data['total_cost'], $order, $data)); ?></strong>
</li>
<?php endif; ?>
<?php if (!empty($data['cost_missing'])) : ?>
<li class="hesabix-v2-profit-details__warn">
<span colspan="2"><?php esc_html_e('بهای تمام‌شده صفر است (قیمت خرید / لایه FIFO یافت نشد)؛ سود نمایشی برابر فروش است و واقعی نیست.', 'hesabix-v2'); ?></span>
</li>
<?php endif; ?>
<?php if (!empty($data['refreshed_at'])) : ?>
<li class="hesabix-v2-profit-details__meta">
<span><?php esc_html_e('آخرین به‌روزرسانی', 'hesabix-v2'); ?></span>
<strong><?php echo esc_html(self::format_datetime((int) $data['refreshed_at'])); ?></strong>
</li>
<?php endif; ?>
</ul>
<?php endif; ?>
</div>
</div>
<?php
return (string) ob_get_clean();
}
/**
* @param array{profit:float,profit_percent:?float,gross_profit:?float,net_profit:?float} $parsed
* @return void
*/
private static function persist(WC_Order $order, $invoice_id, array $parsed)
{
$order->update_meta_data(self::META_PROFIT, (string) $parsed['profit']);
if ($parsed['profit_percent'] !== null) {
$order->update_meta_data(self::META_PROFIT_PERCENT, (string) $parsed['profit_percent']);
} else {
$order->delete_meta_data(self::META_PROFIT_PERCENT);
}
if ($parsed['gross_profit'] !== null) {
$order->update_meta_data(self::META_GROSS, (string) $parsed['gross_profit']);
} else {
$order->delete_meta_data(self::META_GROSS);
}
if ($parsed['net_profit'] !== null) {
$order->update_meta_data(self::META_NET, (string) $parsed['net_profit']);
} else {
$order->delete_meta_data(self::META_NET);
}
$order->update_meta_data(self::META_AT, (string) time());
$order->update_meta_data(self::META_INVOICE_ID, (string) absint($invoice_id));
if (isset($parsed['total_cost']) && $parsed['total_cost'] !== null) {
$order->update_meta_data(self::META_TOTAL_COST, (string) $parsed['total_cost']);
} else {
$order->delete_meta_data(self::META_TOTAL_COST);
}
if (isset($parsed['total_sales']) && $parsed['total_sales'] !== null) {
$order->update_meta_data(self::META_TOTAL_SALES, (string) $parsed['total_sales']);
} else {
$order->delete_meta_data(self::META_TOTAL_SALES);
}
if (!empty($parsed['currency_code'])) {
$order->update_meta_data(self::META_CURRENCY_CODE, strtoupper((string) $parsed['currency_code']));
} else {
// fallback از تنظیمات ارز فاکتور افزونه
if (class_exists('Hesabix_V2_Currency_Service')) {
$row = Hesabix_V2_Currency_Service::resolve_invoice_currency_row();
if ($row && !empty($row['code'])) {
$order->update_meta_data(self::META_CURRENCY_CODE, strtoupper((string) $row['code']));
if (!empty($row['title'])) {
$order->update_meta_data(self::META_CURRENCY_TITLE, (string) $row['title']);
}
} else {
$order->delete_meta_data(self::META_CURRENCY_CODE);
}
} else {
$order->delete_meta_data(self::META_CURRENCY_CODE);
}
}
if (!empty($parsed['currency_title'])) {
$order->update_meta_data(self::META_CURRENCY_TITLE, (string) $parsed['currency_title']);
} elseif (empty($parsed['currency_code'])) {
// title ممکن است همراه fallback کد تنظیم شده باشد
} else {
$order->delete_meta_data(self::META_CURRENCY_TITLE);
}
if (doing_action('woocommerce_update_order')) {
$order->save_meta_data();
} else {
$order->save();
}
}
/**
* قالب‌بندی مبلغ سود به واحد ارز فاکتور حسابیکس (نه ارز ووکامرس).
*
* @param float $amount
* @param WC_Order $order
* @param array|null $profit_data خروجی get_for_order / extract
* @return string
*/
public static function format_money($amount, WC_Order $order, $profit_data = null)
{
$code = null;
$title = null;
if (is_array($profit_data)) {
$code = isset($profit_data['currency_code']) ? $profit_data['currency_code'] : null;
$title = isset($profit_data['currency_title']) ? $profit_data['currency_title'] : null;
}
if (!$code) {
$meta_code = $order->get_meta(self::META_CURRENCY_CODE, true);
if ($meta_code !== '' && $meta_code !== null) {
$code = (string) $meta_code;
}
$meta_title = $order->get_meta(self::META_CURRENCY_TITLE, true);
if ($meta_title !== '' && $meta_title !== null) {
$title = (string) $meta_title;
}
}
if (class_exists('Hesabix_V2_Currency_Service')) {
return Hesabix_V2_Currency_Service::format_hesabix_money((float) $amount, $code, $title);
}
// fallback نادر
$formatted = number_format_i18n((float) $amount, 0);
if ($code && strtoupper((string) $code) === 'IRR') {
return sprintf(__('%s ریال', 'hesabix-v2'), $formatted);
}
return $formatted;
}
/**
* @param float $pct
* @return string
*/
public static function format_percent($pct)
{
$formatted = number_format_i18n($pct, 1);
/* translators: %s: profit percent number */
return sprintf(__('%s٪', 'hesabix-v2'), $formatted);
}
/**
* @param int $ts
* @return string
*/
public static function format_datetime($ts)
{
$ts = (int) $ts;
if ($ts < 1) {
return '—';
}
return wp_date(
get_option('date_format') . ' ' . get_option('time_format'),
$ts
);
}
/**
* @param int|WC_Order $order_or_id
* @return WC_Order|null
*/
private static function resolve_order($order_or_id)
{
if ($order_or_id instanceof WC_Order) {
return $order_or_id;
}
$oid = (int) $order_or_id;
if ($oid < 1) {
return null;
}
$order = wc_get_order($oid);
return ($order instanceof WC_Order) ? $order : null;
}
/**
* @param WC_Order $order
* @param string $key
* @return float|null
*/
private static function meta_float_or_null(WC_Order $order, $key)
{
$v = $order->get_meta($key, true);
if ($v === '' || $v === null) {
return null;
}
return (float) $v;
}
}