ArcWOC/admin/services/class-hesabix-v2-opening-inventory-service.php
morrning 8aedcf2500 Release 4.9.0: invoice profit in WP + marketplace license banner
Show Hesabix invoice profit on ArcWOC orders list and order meta box,
refresh after sync, and surface WooCommerce marketplace license upsell
(hidden when active). Also includes 4.8.2 bank/cash take≤100 fixes.
2026-08-04 12:56:06 +00:00

1158 lines
37 KiB
PHP
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* انتقال موجودی اولیه ووکامرس به تراز افتتاحیه حسابیکس (دسته‌ای + ادغام با سند موجود).
*
* @package Hesabix_V2
* @subpackage Hesabix_V2/admin/services
*/
if (!defined('WPINC')) {
die;
}
class Hesabix_V2_Opening_Inventory_Service
{
const TRANSIENT_PREFIX = 'hesabix_v2_obinv_';
const TRANSIENT_TTL = 86400;
/**
* کش یک‌بار در هر درخواست برای پیش‌نیازهای UI (کاهش تماس تکراری به API).
*
* @var array<string,mixed>|null
*/
private static $connection_prereq_ui_cache = null;
/**
* اشاره‌گر نشست فعال هر کاربر (برای ادامهٔ ایمن پس از خطا / تازه‌سازی صفحه).
*/
const PTR_PREFIX = 'hesabix_v2_obinv_ptr_';
/**
* شناسهٔ کاربر ساختگی برای نشست موجودی افتتاحیهٔ فراخوانی‌شده از پل REST (جدای از مدیر wp-admin).
* نباید با شناسهٔ کاربر واقعی وردپرس یکی باشد.
*/
const BRIDGE_JOB_USER_ID = 919199100;
/**
* عبارت لازم برای تأیید نهایی‌سازی سند وقتی do_post فعال است (پل و رابط ادمین).
*
* @return string
*/
public static function post_confirm_phrase()
{
return 'تایید نهایی سند';
}
/**
* @param int $user_id
* @return string
*/
private static function user_ptr_key($user_id)
{
return self::PTR_PREFIX . (int) $user_id;
}
/**
* @param mixed $v
* @return int|null
*/
private static function normalize_equity_id($v)
{
$i = (int) $v;
return $i > 0 ? $i : null;
}
/**
* هم‌خوانی گزینه‌های اثرگذار بر محاسبه (به‌جز do_post که فقط در نهایی‌سازی است).
*
* @param array $job_opts
* @param array $incoming
* @return bool
*/
private static function opening_inv_core_options_match(array $job_opts, array $incoming)
{
$keys_int = array('inventory_account_id', 'warehouse_id', 'fiscal_year_id', 'currency_id');
foreach ($keys_int as $k) {
if ((int) ($job_opts[ $k ] ?? 0) !== (int) ($incoming[ $k ] ?? 0)) {
return false;
}
}
if (!empty($job_opts['include_tax']) !== !empty($incoming['include_tax'])) {
return false;
}
if (!empty($job_opts['auto_balance_to_equity']) !== !empty($incoming['auto_balance_to_equity'])) {
return false;
}
$jb = isset($job_opts['cost_basis']) ? (string) $job_opts['cost_basis'] : 'regular';
$ib = isset($incoming['cost_basis']) ? (string) $incoming['cost_basis'] : 'regular';
if ($jb !== $ib) {
return false;
}
return self::normalize_equity_id($job_opts['equity_account_id'] ?? null) === self::normalize_equity_id($incoming['equity_account_id'] ?? null);
}
/**
* @param array $api_result
* @return array<string,mixed>|null
*/
public static function get_api_data_array($api_result)
{
if (!is_array($api_result) || empty($api_result['success'])) {
return null;
}
$d = $api_result['data'] ?? null;
return is_array($d) ? $d : null;
}
/**
* اگر سال مالی در وردپرس ذخیره نشده، سال جاری را از حسابیکس می‌گیرد و در option ذخیره می‌کند.
*
* @param Hesabix_V2_Api $api کلاینت با کلید و کسب‌وکار ذخیره‌شده.
* @return int شناسهٔ سال مالی یا ۰ در صورت عدم دسترسی / نبود سال جاری.
*/
public static function sync_and_get_stored_fiscal_year_id(Hesabix_V2_Api $api)
{
$fy = (int) get_option('hesabix_v2_fiscal_year_id');
if ($fy > 0) {
return $fy;
}
$bid = (int) get_option('hesabix_v2_business_id');
if ($bid < 1) {
return 0;
}
$res = $api->get_current_fiscal_year($bid);
if (empty($res['success']) || empty($res['data']) || !is_array($res['data'])) {
return 0;
}
$data = $res['data'];
$id = isset($data['id']) ? (int) $data['id'] : 0;
if ($id < 1) {
return 0;
}
update_option('hesabix_v2_fiscal_year_id', $id);
self::$connection_prereq_ui_cache = null;
if (class_exists('Hesabix_V2_Order_Fiscal_Service', false)) {
Hesabix_V2_Order_Fiscal_Service::invalidate_bounds_cache();
}
return $id;
}
/**
* پیش‌نیازهای اتصال برای تب موجودی افتتاحیه (چک‌لیست + مقادیر اعتبارسنجی JS).
*
* @return array{prereq: array{fiscal_year_id:int, currency_id:int}, checklist: array<string,bool>}
*/
public static function get_connection_prereq_for_ui()
{
if (is_array(self::$connection_prereq_ui_cache)) {
return self::$connection_prereq_ui_cache;
}
$enabled = (bool) get_option('hesabix_v2_enabled');
$api_key = (bool) get_option('hesabix_v2_api_key');
$biz_ok = (int) get_option('hesabix_v2_business_id') > 0;
$wh_ok = (int) get_option('hesabix_v2_default_warehouse_id') > 0;
if (!$wh_ok) {
$obp = get_option('hesabix_v2_opening_inventory_prefs', array());
if (is_array($obp) && !empty($obp['warehouse_override']) && (int) $obp['warehouse_override'] > 0) {
$wh_ok = true;
}
}
$fy = (int) get_option('hesabix_v2_fiscal_year_id');
$currency_resolved = 0;
if ($enabled && $api_key && $biz_ok) {
$api = new Hesabix_V2_Api();
if ($fy < 1) {
$fy = self::sync_and_get_stored_fiscal_year_id($api);
}
$currency_resolved = (int) Hesabix_V2_Currency_Service::resolve_invoice_currency_id($api);
}
$out = array(
'prereq' => array(
'fiscal_year_id' => $fy,
'currency_id' => $currency_resolved,
),
'checklist' => array(
'enabled' => $enabled,
'api_key' => $api_key,
'business' => $biz_ok,
'fiscal_year' => $fy > 0,
'warehouse' => $wh_ok,
'currency' => $currency_resolved > 0,
),
);
self::$connection_prereq_ui_cache = $out;
return $out;
}
/**
* خالی کردن کش پیش‌نیاز UI (مثلاً پس از تغییر سال مالی در option).
*
* @return void
*/
public static function invalidate_connection_prereq_ui_cache()
{
self::$connection_prereq_ui_cache = null;
}
/**
* خلاصهٔ نشست نیمه‌تمام کاربر (در صورت وجود).
*
* @param int $user_id
* @return array{job_id:string, cursor:int, total:int, needs_finalize?:bool}|null
*/
public static function get_pending_job_summary_for_user($user_id)
{
$user_id = (int) $user_id;
if ($user_id < 1 || get_option('hesabix_v2_opening_inventory_completed')) {
return null;
}
$ptr_key = self::PTR_PREFIX . $user_id;
$jid = get_transient($ptr_key);
if (!is_string($jid) || $jid === '') {
return null;
}
$job = self::get_job($jid, $user_id);
if (!$job) {
delete_transient($ptr_key);
return null;
}
$items = isset($job['items']) && is_array($job['items']) ? $job['items'] : array();
$total = count($items);
$cursor = (int) ($job['cursor'] ?? 0);
if ($total < 1) {
return null;
}
if ($cursor < $total) {
return array(
'job_id' => $jid,
'cursor' => $cursor,
'total' => $total,
'needs_finalize' => false,
);
}
$opts = isset($job['options']) && is_array($job['options']) ? $job['options'] : array();
if (!empty($opts['do_post'])) {
return array(
'job_id' => $jid,
'cursor' => $cursor,
'total' => $total,
'needs_finalize' => true,
);
}
return null;
}
/**
* کنترل سمت سرور: حساب‌های انتخابی باید برگ دفترکل باشند (بدون زیرمجموعه در لیست تخت).
*
* @param Hesabix_V2_Api $api
* @param int $inventory_account_id
* @param int $equity_account_id
* @param bool $require_equity
* @return array{0:bool, 1:?string} [ok, message]
*/
public static function validate_leaf_account_selection(Hesabix_V2_Api $api, $inventory_account_id, $equity_account_id, $require_equity)
{
$inventory_account_id = (int) $inventory_account_id;
$equity_account_id = (int) $equity_account_id;
$to_check = array($inventory_account_id);
if ($require_equity && $equity_account_id > 0) {
$to_check[] = $equity_account_id;
}
$res = $api->get_accounts_flat();
$data = self::get_api_data_array($res);
if (!is_array($data) || empty($data['items']) || !is_array($data['items'])) {
return array(false, __('فهرست حساب‌ها برای کنترل انتخاب برنگشت.', 'hesabix-v2'));
}
$has_children = array();
$known = array();
foreach ($data['items'] as $row) {
if (!is_array($row) || empty($row['id'])) {
continue;
}
$known[ (int) $row['id'] ] = true;
$pp = isset($row['parent_id']) ? (int) $row['parent_id'] : 0;
if ($pp > 0) {
$has_children[ $pp ] = true;
}
}
foreach ($to_check as $aid) {
if ($aid < 1) {
continue;
}
if (empty($known[ $aid ])) {
return array(
false,
sprintf(
/* translators: %d: account id */
__('شناسهٔ حساب %d در فهرست دفترکل این کسب‌وکار دیده نشد.', 'hesabix-v2'),
$aid
),
);
}
if (!empty($has_children[ $aid ])) {
return array(
false,
sprintf(
/* translators: %d: account id */
__('حساب %d دارای زیرحساب است؛ فقط حساب برگ (بدون زیرمجموعه) قابل انتخاب است.', 'hesabix-v2'),
$aid
),
);
}
}
return array(true, null);
}
/**
* پیش‌نمایش اقلام و تخمین دسته‌ها (بدون ایجاد job).
*
* @param array<string,mixed> $post_like همان کلیدهای نزدیک collectPayload.
* @return array<string,mixed>
*/
public static function build_preview_payload(array $post_like)
{
if (!get_option('hesabix_v2_enabled')) {
return array('success' => false, 'message' => __('افزونه حسابیکس غیرفعال است.', 'hesabix-v2'));
}
$api = new Hesabix_V2_Api();
$fy = self::sync_and_get_stored_fiscal_year_id($api);
if ($fy < 1) {
return array(
'success' => false,
'message' => __(
'سال مالی جاری از حسابیکس خوانده نشد. تب اتصال را باز کنید یا مجوز سال مالی را بررسی کنید.',
'hesabix-v2'
),
);
}
$cur = Hesabix_V2_Currency_Service::resolve_invoice_currency_id($api);
if ($cur < 1) {
return array('success' => false, 'message' => __('ارز فاکتور / سند در تنظیمات مشخص نشده است.', 'hesabix-v2'));
}
$warehouse_id = isset($post_like['warehouse_id']) ? (int) $post_like['warehouse_id'] : 0;
if ($warehouse_id < 1) {
$warehouse_id = (int) get_option('hesabix_v2_default_warehouse_id');
}
if ($warehouse_id < 1) {
return array('success' => false, 'message' => __('انبار پیش‌فرض را در تب فاکتور انتخاب کنید.', 'hesabix-v2'));
}
$targets = self::collect_wc_stock_targets();
if (empty($targets)) {
return array('success' => false, 'message' => __('هیچ کالای منتشرشده‌ای با موجودی مدیریت‌شده و شمارش > 0 یافت نشد.', 'hesabix-v2'));
}
$cost_basis = isset($post_like['cost_basis']) ? (string) $post_like['cost_basis'] : 'regular';
if (!in_array($cost_basis, array('regular', 'sale', 'zero'), true)) {
$cost_basis = 'regular';
}
$opt_preview = array(
'include_tax' => !empty($post_like['include_tax']),
'cost_basis' => $cost_basis,
);
$batch_size = isset($post_like['batch_size']) ? max(3, min(40, (int) $post_like['batch_size'])) : 12;
$total = count($targets);
$batches_est = (int) max(1, (int) ceil($total / $batch_size));
$samples = array();
$slice = array_slice($targets, 0, 12);
foreach ($slice as $t) {
if (!function_exists('wc_get_product')) {
break;
}
$p = wc_get_product((int) $t['product_id']);
if (!$p) {
continue;
}
$cost = self::resolve_unit_cost_for_opening($p, $opt_preview, $api);
$samples[] = array(
'wc_id' => (int) $t['product_id'],
'name' => (string) $p->get_name(),
'qty' => (float) $p->get_stock_quantity(),
'unit_cost' => $cost,
'kind' => isset($t['kind']) ? (string) $t['kind'] : '',
);
}
$ob = self::get_api_data_array($api->get_opening_balance($fy));
$posted = false;
if (is_array($ob)) {
$ei = isset($ob['extra_info']) && is_array($ob['extra_info']) ? $ob['extra_info'] : array();
$posted = !empty($ei['posted']);
}
self::$connection_prereq_ui_cache = null;
$ui = self::get_connection_prereq_for_ui();
return array(
'success' => true,
'total' => $total,
'batch_size' => $batch_size,
'batches_est' => $batches_est,
'samples' => $samples,
'opening_balance_posted' => $posted,
'prereq' => array(
'fiscal_year_id' => $fy,
'currency_id' => $cur,
),
'checklist' => $ui['checklist'],
);
}
/**
* اقلام قابل‌ثبت: ساده + واریانت‌ها؛ فقط دارای مدیریت موجودی و موجودی > 0.
*
* @return array<int, array{product_id:int, parent_id:?int, kind:string}>
*/
public static function collect_wc_stock_targets()
{
if (!function_exists('wc_get_products')) {
return array();
}
$ids = wc_get_products(
array(
'status' => Hesabix_V2_Product_Service::syncable_statuses(),
'limit' => -1,
'return' => 'ids',
)
);
if (!is_array($ids)) {
return array();
}
$sync = Hesabix_V2_Invoice_Helper::normalize_sync_settings(get_option('hesabix_v2_sync_settings', array()));
$inv_policy = isset($sync['track_inventory_policy']) ? (string) $sync['track_inventory_policy'] : 'wc';
$out = array();
foreach ($ids as $pid) {
$pid = (int) $pid;
if ($pid < 1) {
continue;
}
$p = wc_get_product($pid);
if (!$p || $p->is_virtual()) {
continue;
}
if ($p->is_type('variable')) {
foreach ($p->get_children() as $vid) {
$vid = (int) $vid;
if ($vid < 1) {
continue;
}
$v = wc_get_product($vid);
if (!$v || $v->is_virtual() || !Hesabix_V2_Mapper::wc_product_qualifies_for_opening_stock_qty($v, $inv_policy)) {
continue;
}
$q = (float) $v->get_stock_quantity();
if ($q <= 0) {
continue;
}
$out[] = array(
'product_id' => $vid,
'parent_id' => $pid,
'kind' => 'variation',
);
}
continue;
}
if ($p->is_type('simple')) {
if (!Hesabix_V2_Mapper::wc_product_qualifies_for_opening_stock_qty($p, $inv_policy)) {
continue;
}
$q = (float) $p->get_stock_quantity();
if ($q <= 0) {
continue;
}
$out[] = array(
'product_id' => $pid,
'parent_id' => null,
'kind' => 'simple',
);
}
}
return $out;
}
/**
* بهای واحد برای خط موجودی (بعرض ارز حسابیکس).
*
* @param WC_Product $product
* @param array $options include_tax, cost_basis: regular|sale|zero
* @param Hesabix_V2_Api $api
* @return float
*/
public static function resolve_unit_cost_for_opening($product, array $options, Hesabix_V2_Api $api)
{
$basis = isset($options['cost_basis']) ? (string) $options['cost_basis'] : 'regular';
if ($basis === 'zero') {
return 0.0;
}
$inc_tax = !empty($options['include_tax']);
if ($basis === 'sale') {
$raw = (float) $product->get_price();
} else {
$raw = (float) $product->get_regular_price();
if ($raw <= 0) {
$raw = (float) $product->get_price();
}
}
$price_for_tax = $raw;
if (function_exists('wc_get_price_excluding_tax') && function_exists('wc_get_price_including_tax')) {
if ($inc_tax) {
$price_for_tax = (float) wc_get_price_including_tax(array('qty' => 1, 'product' => $product));
} else {
$price_for_tax = (float) wc_get_price_excluding_tax(array('qty' => 1, 'product' => $product));
}
}
if ($price_for_tax < 0) {
$price_for_tax = 0.0;
}
$gate = Hesabix_V2_Currency_Service::evaluate_currency_sync($api, $product->get_currency());
if (empty($gate['ok'])) {
return 0.0;
}
$f = isset($gate['factor']) ? (float) $gate['factor'] : 1.0;
if ($f <= 0 || is_nan($f)) {
$f = 1.0;
}
return Hesabix_V2_Validation::sanitize_price($price_for_tax * $f);
}
/**
* تبدیل پاسخ سند باز شده به account_lines + inventory_lines برای upsert مجدد.
*
* @param array<string,mixed>|null $doc
* @return array{account_lines: array, inventory_lines: array, document_date: ?string, description: ?string}
*/
public static function document_to_payload_parts($doc)
{
$account_lines = array();
$inventory_lines = array();
$document_date = null;
$description = null;
$inv_acc = null;
if (!is_array($doc)) {
return array(
'account_lines' => array(),
'inventory_lines' => array(),
'document_date' => null,
'description' => null,
);
}
if (isset($doc['document_date'])) {
$document_date = is_string($doc['document_date']) ? $doc['document_date'] : null;
}
if (isset($doc['description'])) {
$description = is_string($doc['description']) ? $doc['description'] : null;
}
$ei = isset($doc['extra_info']) && is_array($doc['extra_info']) ? $doc['extra_info'] : array();
if (isset($ei['inventory_account_id'])) {
$inv_acc = (int) $ei['inventory_account_id'];
}
if (isset($doc['inventory_account_id'])) {
$inv_acc = (int) $doc['inventory_account_id'];
}
$lines = isset($doc['lines']) && is_array($doc['lines']) ? $doc['lines'] : array();
foreach ($lines as $ln) {
if (!is_array($ln)) {
continue;
}
$pid = isset($ln['product_id']) ? (int) $ln['product_id'] : 0;
$qty = isset($ln['quantity']) ? (float) $ln['quantity'] : 0.0;
if ($pid > 0 && $qty > 0) {
$info = isset($ln['extra_info']) && is_array($ln['extra_info']) ? $ln['extra_info'] : array();
$info['movement'] = 'in';
$inventory_lines[] = array(
'product_id' => $pid,
'quantity' => $qty,
'description' => isset($ln['description']) ? $ln['description'] : null,
'extra_info' => $info,
);
continue;
}
$d = isset($ln['debit']) ? (float) $ln['debit'] : 0.0;
$c = isset($ln['credit']) ? (float) $ln['credit'] : 0.0;
if ($d <= 0 && $c <= 0) {
continue;
}
$desc = isset($ln['description']) ? (string) $ln['description'] : '';
$aid = isset($ln['account_id']) ? (int) $ln['account_id'] : 0;
if ($inv_acc && $aid === $inv_acc && $desc === 'موجودی ابتدای دوره') {
continue;
}
if ($desc === 'بستن اختلاف تراز افتتاحیه') {
continue;
}
$account_lines[] = array(
'account_id' => $ln['account_id'] ?? null,
'person_id' => $ln['person_id'] ?? null,
'bank_account_id' => $ln['bank_account_id'] ?? null,
'cash_register_id' => $ln['cash_register_id'] ?? null,
'petty_cash_id' => $ln['petty_cash_id'] ?? null,
'debit' => $d,
'credit' => $c,
'description' => $ln['description'] ?? null,
'extra_info' => $ln['extra_info'] ?? null,
);
}
return array(
'account_lines' => $account_lines,
'inventory_lines' => $inventory_lines,
'document_date' => $document_date,
'description' => $description,
);
}
/**
* ادغام خطوط موجودی بدون تکرار (کالا + انبار).
*
* @param array $a
* @param array $b
* @return array
*/
public static function merge_inventory_lines($a, $b)
{
$map = array();
foreach (array_merge($a, $b) as $row) {
if (!is_array($row) || empty($row['product_id'])) {
continue;
}
$info = isset($row['extra_info']) && is_array($row['extra_info']) ? $row['extra_info'] : array();
$wid = isset($info['warehouse_id']) ? (int) $info['warehouse_id'] : 0;
$key = (int) $row['product_id'] . ':' . $wid;
$map[ $key ] = $row;
}
return array_values($map);
}
/**
* ساخت خط موجودی از محصول ووکامرس.
*
* @param WC_Product $product
* @param int $hesabix_product_id
* @param int $warehouse_id
* @param array $options
* @param Hesabix_V2_Api $api
* @return array<string,mixed>|null
*/
public static function build_inventory_line_from_wc_product($product, $hesabix_product_id, $warehouse_id, array $options, Hesabix_V2_Api $api)
{
$hesabix_product_id = (int) $hesabix_product_id;
$warehouse_id = (int) $warehouse_id;
if ($hesabix_product_id < 1 || $warehouse_id < 1) {
return null;
}
$qty = (float) $product->get_stock_quantity();
if ($qty <= 0) {
return null;
}
$unit_cost = self::resolve_unit_cost_for_opening($product, $options, $api);
$extra = array(
'warehouse_id' => $warehouse_id,
'movement' => 'in',
'cost_price' => $unit_cost,
);
return array(
'product_id' => $hesabix_product_id,
'quantity' => $qty,
'description' => sprintf(
/* translators: %s: product name */
__('موجودی اولیه ووکامرس — %s', 'hesabix-v2'),
$product->get_name()
),
'extra_info' => $extra,
);
}
/**
* آماده‌سازی کار دسته‌ای.
*
* @param int $user_id
* @param array $options
* @return array{success:bool, message?:string, job_id?:string, total?:int}
*/
public static function job_prepare($user_id, array $options)
{
$user_id = (int) $user_id;
if ($user_id < 1) {
return array('success' => false, 'message' => __('کاربر نامعتبر است.', 'hesabix-v2'));
}
if (get_option('hesabix_v2_opening_inventory_completed')) {
return array('success' => false, 'message' => __('این عمل قبلاً با موفقیت انجام شده و دیگر قابل تکرار نیست.', 'hesabix-v2'));
}
if (!get_option('hesabix_v2_enabled')) {
return array('success' => false, 'message' => __('افزونه حسابیکس غیرفعال است.', 'hesabix-v2'));
}
$api = new Hesabix_V2_Api();
$fy = self::sync_and_get_stored_fiscal_year_id($api);
if ($fy < 1) {
return array(
'success' => false,
'message' => __(
'سال مالی جاری از حسابیکس خوانده نشد. اتصال و مجوز مشاهدهٔ سال مالی (fiscal_years.view) را بررسی کنید؛ سپس در تب اتصال یک‌بار «تست اتصال» بزنید یا صفحه را تازه کنید.',
'hesabix-v2'
),
);
}
$cur = Hesabix_V2_Currency_Service::resolve_invoice_currency_id($api);
if ($cur < 1) {
return array('success' => false, 'message' => __('ارز فاکتور / سند در تنظیمات مشخص نشده است.', 'hesabix-v2'));
}
$warehouse_id = isset($options['warehouse_id']) ? (int) $options['warehouse_id'] : 0;
if ($warehouse_id < 1) {
$warehouse_id = (int) get_option('hesabix_v2_default_warehouse_id');
}
if ($warehouse_id < 1) {
return array('success' => false, 'message' => __('انبار پیش‌فرض را در تب فاکتور انتخاب کنید.', 'hesabix-v2'));
}
$inv_acc = isset($options['inventory_account_id']) ? (int) $options['inventory_account_id'] : 0;
if ($inv_acc < 1) {
return array('success' => false, 'message' => __('حساب موجودی (کالا) را انتخاب کنید.', 'hesabix-v2'));
}
$auto_eq = !empty($options['auto_balance_to_equity']);
$eq_acc = isset($options['equity_account_id']) ? (int) $options['equity_account_id'] : 0;
if ($auto_eq && $eq_acc < 1) {
return array('success' => false, 'message' => __('برای بستن خودکار تراز، حساب حقوق صاحبان سهام را انتخاب کنید.', 'hesabix-v2'));
}
list($leaf_ok, $leaf_msg) = self::validate_leaf_account_selection($api, $inv_acc, $eq_acc, $auto_eq);
if (!$leaf_ok) {
return array(
'success' => false,
'message' => $leaf_msg ? $leaf_msg : __('حساب انتخاب‌شده برای تراز افتتاحیه مجاز نیست.', 'hesabix-v2'),
);
}
$cost_basis = isset($options['cost_basis']) ? (string) $options['cost_basis'] : 'regular';
if (!in_array($cost_basis, array('regular', 'sale', 'zero'), true)) {
$cost_basis = 'regular';
}
$incoming_core = array(
'include_tax' => !empty($options['include_tax']),
'cost_basis' => $cost_basis,
'auto_balance_to_equity' => $auto_eq,
'inventory_account_id' => $inv_acc,
'equity_account_id' => $eq_acc > 0 ? $eq_acc : null,
'warehouse_id' => $warehouse_id,
'fiscal_year_id' => $fy,
'currency_id' => $cur,
);
$ptr_key = self::user_ptr_key($user_id);
$existing_id = get_transient($ptr_key);
if (is_string($existing_id) && $existing_id !== '') {
$existing_job = self::get_job($existing_id, $user_id);
if (!$existing_job) {
delete_transient($ptr_key);
} else {
$ex_items = isset($existing_job['items']) && is_array($existing_job['items']) ? $existing_job['items'] : array();
$ex_cursor = (int) ($existing_job['cursor'] ?? 0);
$job_opts = isset($existing_job['options']) && is_array($existing_job['options']) ? $existing_job['options'] : array();
if (!self::opening_inv_core_options_match($job_opts, $incoming_core)) {
return array(
'success' => false,
'message' => __('نشست نیمه‌تمام با گزینه‌های فعلی فرم هم‌خوان نیست. همان گزینه‌های اجرای قبلی را برگردانید یا پس از اتمام نشست دوباره تلاش کنید.', 'hesabix-v2'),
);
}
if ($ex_cursor < count($ex_items)) {
return array(
'success' => true,
'job_id' => $existing_id,
'total' => count($ex_items),
'resumed' => true,
'message' => __('ادامهٔ نشست قبلی (همان دسته باقی‌مانده).', 'hesabix-v2'),
);
}
$existing_job['options']['do_post'] = !empty($options['do_post']);
set_transient(self::TRANSIENT_PREFIX . $existing_id, $existing_job, self::TRANSIENT_TTL);
return array(
'success' => true,
'job_id' => $existing_id,
'total' => count($ex_items),
'needs_finalize' => true,
'message' => __('دسته‌ها قبلاً ذخیره شده‌اند؛ فقط نهایی‌سازی مانده است.', 'hesabix-v2'),
);
}
}
$ob = self::get_api_data_array($api->get_opening_balance($fy));
if (is_array($ob)) {
$ei = isset($ob['extra_info']) && is_array($ob['extra_info']) ? $ob['extra_info'] : array();
if (!empty($ei['posted'])) {
return array('success' => false, 'message' => __('تراز افتتاحیه این سال مالی در حسابیکس نهایی شده؛ امکان ویرایش نیست.', 'hesabix-v2'));
}
}
$targets = self::collect_wc_stock_targets();
if (empty($targets)) {
return array('success' => false, 'message' => __('هیچ کالای منتشرشده‌ای با موجودی مدیریت‌شده و شمارش > 0 یافت نشد.', 'hesabix-v2'));
}
$job_id = wp_generate_password(20, false, false);
$batch = isset($options['batch_size']) ? max(3, min(40, (int) $options['batch_size'])) : 12;
$payload = array(
'user_id' => $user_id,
'cursor' => 0,
'batch_size' => $batch,
'items' => $targets,
'options' => array(
'include_tax' => $incoming_core['include_tax'],
'cost_basis' => $incoming_core['cost_basis'],
'auto_balance_to_equity' => $incoming_core['auto_balance_to_equity'],
'inventory_account_id' => $inv_acc,
'equity_account_id' => $incoming_core['equity_account_id'],
'warehouse_id' => $warehouse_id,
'do_post' => !empty($options['do_post']),
'fiscal_year_id' => $fy,
'currency_id' => $cur,
),
'created_at' => time(),
);
set_transient(self::TRANSIENT_PREFIX . $job_id, $payload, self::TRANSIENT_TTL);
set_transient($ptr_key, $job_id, self::TRANSIENT_TTL);
Hesabix_V2_Log_Service::info(
'Opening inventory job prepared',
array(
'job_id' => $job_id,
'user_id' => $user_id,
'total_items' => count($targets),
'fiscal_year_id' => $fy,
)
);
return array(
'success' => true,
'job_id' => $job_id,
'total' => count($targets),
'message' => __('کار آماده شد.', 'hesabix-v2'),
);
}
/**
* @param string $job_id
* @param int $user_id
* @return array<string,mixed>|null
*/
public static function get_job($job_id, $user_id)
{
$job_id = sanitize_key((string) $job_id);
if ($job_id === '') {
return null;
}
$raw = get_transient(self::TRANSIENT_PREFIX . $job_id);
if (!is_array($raw) || (int) ($raw['user_id'] ?? 0) !== (int) $user_id) {
return null;
}
return $raw;
}
/**
* @param string $job_id
* @return void
*/
public static function delete_job($job_id)
{
$job_id = sanitize_key((string) $job_id);
if ($job_id !== '') {
delete_transient(self::TRANSIENT_PREFIX . $job_id);
}
}
/**
* ثبت درخواست توقف: دستهٔ در حال اجرا تمام می‌شود؛ فراخوانی بعدی {@see job_run_batch} بدون پردازش برمی‌گردد.
*
* @param string $job_id
* @param int $user_id
* @return array{success:bool, message?:string}
*/
public static function mark_job_cancel_requested($job_id, $user_id)
{
$job_id = sanitize_key((string) $job_id);
if ($job_id === '') {
return array(
'success' => false,
'message' => __('نشست کار نامعتبر است.', 'hesabix-v2'),
);
}
$job = self::get_job($job_id, $user_id);
if (!$job) {
return array(
'success' => false,
'message' => __('نشست کار یافت نشد.', 'hesabix-v2'),
);
}
$job['cancel_requested'] = true;
set_transient(self::TRANSIENT_PREFIX . $job_id, $job, self::TRANSIENT_TTL);
return array(
'success' => true,
'message' => __('توقف پس از اتمام دستهٔ جاری ثبت شد.', 'hesabix-v2'),
);
}
/**
* اجرای یک دسته.
*
* @param string $job_id
* @param int $user_id
* @return array{success:bool, message?:string, cursor?:int, total?:int, done?:bool, cancelled?:bool, detail?:array}
*/
public static function job_run_batch($job_id, $user_id)
{
$job = self::get_job($job_id, $user_id);
if (!$job) {
return array('success' => false, 'message' => __('نشست کار نامعتبر یا منقضی است. از نو شروع کنید.', 'hesabix-v2'));
}
$sync = new Hesabix_V2_Sync_Service();
$api = new Hesabix_V2_Api();
$opts = isset($job['options']) && is_array($job['options']) ? $job['options'] : array();
$fy = (int) ($opts['fiscal_year_id'] ?? 0);
$currency_id = (int) ($opts['currency_id'] ?? 0);
$warehouse_id = (int) ($opts['warehouse_id'] ?? 0);
$batch_size = (int) ($job['batch_size'] ?? 12);
$items = isset($job['items']) && is_array($job['items']) ? $job['items'] : array();
$cursor = (int) ($job['cursor'] ?? 0);
$total = count($items);
if (!empty($job['cancel_requested'])) {
unset($job['cancel_requested']);
set_transient(self::TRANSIENT_PREFIX . sanitize_key((string) $job_id), $job, self::TRANSIENT_TTL);
return array(
'success' => true,
'cancelled' => true,
'cursor' => $cursor,
'total' => $total,
'done' => false,
'detail' => array(),
'message' => __('پردازش طبق درخواست توقف متوقف شد. نشست ذخیره مانده؛ بعداً با «شروع ثبت…» ادامه دهید.', 'hesabix-v2'),
);
}
$slice = array_slice($items, $cursor, $batch_size);
if (empty($slice)) {
return array(
'success' => true,
'done' => true,
'cursor' => $cursor,
'total' => $total,
'message' => __('همهٔ مراحل پردازش شد.', 'hesabix-v2'),
);
}
$new_lines = array();
$detail = array();
$blocking = false;
foreach ($slice as $row) {
$wc_pid = (int) $row['product_id'];
$parent = isset($row['parent_id']) ? (int) $row['parent_id'] : 0;
$product = wc_get_product($wc_pid);
if (!$product) {
$detail[] = array('wc_id' => $wc_pid, 'ok' => false, 'message' => __('محصول حذف شده است.', 'hesabix-v2'));
continue;
}
if ($parent > 0) {
$res = $sync->sync_product($parent, $wc_pid);
} else {
$res = $sync->sync_product($wc_pid);
}
if (empty($res['success']) || empty($res['hesabix_id'])) {
$blocking = true;
$detail[] = array(
'wc_id' => $wc_pid,
'ok' => false,
'message' => isset($res['message']) ? (string) $res['message'] : __('همگام کالا ناموفق', 'hesabix-v2'),
);
continue;
}
$line = self::build_inventory_line_from_wc_product(
$product,
(int) $res['hesabix_id'],
$warehouse_id,
$opts,
$api
);
if (!$line) {
$detail[] = array('wc_id' => $wc_pid, 'ok' => true, 'message' => __('بدون خط (موجودی صفر)', 'hesabix-v2'));
continue;
}
$new_lines[] = $line;
$detail[] = array('wc_id' => $wc_pid, 'ok' => true, 'hesabix_product' => (int) $res['hesabix_id']);
}
if ($blocking) {
return array(
'success' => false,
'message' => __('در این دسته خطایی رخ داد؛ پس از رفع مشکل دوباره «شروع ثبت موجودی اولیه» را بزنید — همان نشست ادامه پیدا می‌کند.', 'hesabix-v2'),
'cursor' => $cursor,
'total' => $total,
'detail' => $detail,
);
}
if (!empty($new_lines)) {
$ob_raw = $api->get_opening_balance($fy);
$doc = self::get_api_data_array($ob_raw);
$parts = self::document_to_payload_parts($doc);
$merged_inv = self::merge_inventory_lines($parts['inventory_lines'], $new_lines);
$doc_date = $parts['document_date'];
if (!$doc_date) {
$fy_info = $api->get_current_fiscal_year((int) get_option('hesabix_v2_business_id'));
$fy_data = self::get_api_data_array($fy_info);
if (is_array($fy_data) && !empty($fy_data['start_date'])) {
$doc_date = is_string($fy_data['start_date']) ? $fy_data['start_date'] : null;
}
if (!$doc_date) {
$doc_date = gmdate('Y-m-d');
}
}
$desc = $parts['description'];
if (!$desc) {
$desc = __('موجودی اولیه ووکامرس', 'hesabix-v2');
}
$body = array(
'fiscal_year_id' => $fy,
'document_date' => $doc_date,
'currency_id' => $currency_id,
'account_lines' => $parts['account_lines'],
'inventory_lines' => $merged_inv,
'inventory_account_id' => (int) $opts['inventory_account_id'],
'auto_balance_to_equity' => !empty($opts['auto_balance_to_equity']),
'equity_account_id' => !empty($opts['equity_account_id']) ? (int) $opts['equity_account_id'] : null,
'description' => $desc,
);
$put = $api->upsert_opening_balance($body);
if (empty($put['success'])) {
$msg = isset($put['message']) ? (string) $put['message'] : __('ذخیرهٔ تراز افتتاحیه ناموفق بود.', 'hesabix-v2');
return array(
'success' => false,
'message' => $msg,
'cursor' => $cursor,
'total' => $total,
'detail' => $detail,
);
}
}
$cursor += count($slice);
$job['cursor'] = $cursor;
set_transient(self::TRANSIENT_PREFIX . $job_id, $job, self::TRANSIENT_TTL);
$done = $cursor >= $total;
return array(
'success' => true,
'cursor' => $cursor,
'total' => $total,
'done' => $done,
'detail' => $detail,
'message' => $done
? __('پردازش موجودی تمام شد؛ در صورت نیاز نهایی‌سازی را بزنید.', 'hesabix-v2')
: __('دسته با موفقیت ذخیره شد.', 'hesabix-v2'),
);
}
/**
* نهایی‌سازی: در صورت انتخاب post، و علامت‌گذاری یک‌بار مصرف.
*
* @param string $job_id
* @param int $user_id
* @return array{success:bool, message?:string}
*/
public static function job_finalize($job_id, $user_id)
{
$job = self::get_job($job_id, $user_id);
if (!$job) {
return array('success' => false, 'message' => __('نشست کار نامعتبر است.', 'hesabix-v2'));
}
$items = isset($job['items']) && is_array($job['items']) ? $job['items'] : array();
$cursor = (int) ($job['cursor'] ?? 0);
if ($cursor < count($items)) {
return array('success' => false, 'message' => __('هنوز مراحل باقی مانده است.', 'hesabix-v2'));
}
$opts = isset($job['options']) && is_array($job['options']) ? $job['options'] : array();
$api = new Hesabix_V2_Api();
if (!empty($opts['do_post'])) {
$fy = (int) ($opts['fiscal_year_id'] ?? 0);
$pr = $api->post_opening_balance($fy);
if (empty($pr['success'])) {
$msg = isset($pr['message']) ? (string) $pr['message'] : __('نهایی‌سازی تراز افتتاحیه ناموفق بود.', 'hesabix-v2');
return array('success' => false, 'message' => $msg);
}
}
update_option('hesabix_v2_opening_inventory_completed', true);
$ptr_k = self::user_ptr_key((int) ($job['user_id'] ?? 0));
delete_transient($ptr_k);
self::delete_job($job_id);
Hesabix_V2_Log_Service::info(
'WooCommerce opening inventory pushed to Hesabix opening balance',
array(
'entity_type' => 'opening_balance',
'posted' => !empty($opts['do_post']),
'job_id' => $job_id,
'items' => count($items),
)
);
return array(
'success' => true,
'message' => !empty($opts['do_post'])
? __('تراز افتتاحیه ذخیره و نهایی شد. این عمل دیگر در دسترس نیست.', 'hesabix-v2')
: __('تراز افتتاحیه ذخیره شد. این عمل دیگر در دسترس نیست.', 'hesabix-v2'),
);
}
}