Compare commits

...

4 commits
master ... test

Author SHA1 Message Date
Gloomy b216a98712 new fix 2025-08-18 18:35:33 +00:00
Gloomy 2a8f5e3e87 bug fix 2025-08-11 11:55:31 +00:00
Gloomy a19175cfb4 bug fix 2025-08-11 11:50:24 +00:00
Gloomy ccc3fb3c6f beta version 2025-08-11 11:15:22 +00:00
89 changed files with 16899 additions and 1190 deletions

View file

@ -16,6 +16,7 @@
"doctrine/orm": "^3.2",
"dompdf/dompdf": "^3.0",
"melipayamak/php": "^1.0",
"morilog/jalali": "*",
"mpdf/mpdf": "^8.2",
"nelmio/api-doc-bundle": "^4.35",
"nelmio/cors-bundle": "^2.5",

View file

@ -4,8 +4,75 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "43db0ad2bb94569ed6d44cabf503210e",
"content-hash": "01b5daf5a6fd011b4eb616e0e4ae18fe",
"packages": [
{
"name": "beberlei/assert",
"version": "v3.3.3",
"source": {
"type": "git",
"url": "https://github.com/beberlei/assert.git",
"reference": "b5fd8eacd8915a1b627b8bfc027803f1939734dd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/beberlei/assert/zipball/b5fd8eacd8915a1b627b8bfc027803f1939734dd",
"reference": "b5fd8eacd8915a1b627b8bfc027803f1939734dd",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "*",
"phpstan/phpstan": "*",
"phpunit/phpunit": ">=6.0.0",
"yoast/phpunit-polyfills": "^0.1.0"
},
"suggest": {
"ext-intl": "Needed to allow Assertion::count(), Assertion::isCountable(), Assertion::minCount(), and Assertion::maxCount() to operate on ResourceBundles"
},
"type": "library",
"autoload": {
"files": [
"lib/Assert/functions.php"
],
"psr-4": {
"Assert\\": "lib/Assert"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de",
"role": "Lead Developer"
},
{
"name": "Richard Quadling",
"email": "rquadling@gmail.com",
"role": "Collaborator"
}
],
"description": "Thin assertion library for input validation in business models.",
"keywords": [
"assert",
"assertion",
"validation"
],
"support": {
"issues": "https://github.com/beberlei/assert/issues",
"source": "https://github.com/beberlei/assert/tree/v3.3.3"
},
"time": "2024-07-15T13:18:35+00:00"
},
{
"name": "brick/math",
"version": "0.12.3",
@ -66,6 +133,75 @@
],
"time": "2025-02-28T13:11:00+00:00"
},
{
"name": "carbonphp/carbon-doctrine-types",
"version": "3.2.0",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon-doctrine-types.git",
"reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d",
"reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"conflict": {
"doctrine/dbal": "<4.0.0 || >=5.0.0"
},
"require-dev": {
"doctrine/dbal": "^4.0.0",
"nesbot/carbon": "^2.71.0 || ^3.0.0",
"phpunit/phpunit": "^10.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Carbon\\Doctrine\\": "src/Carbon/Doctrine/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "KyleKatarn",
"email": "kylekatarnls@gmail.com"
}
],
"description": "Types to use Carbon in Doctrine",
"keywords": [
"carbon",
"date",
"datetime",
"doctrine",
"time"
],
"support": {
"issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues",
"source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0"
},
"funding": [
{
"url": "https://github.com/kylekatarnls",
"type": "github"
},
{
"url": "https://opencollective.com/Carbon",
"type": "open_collective"
},
{
"url": "https://tidelift.com/funding/github/packagist/nesbot/carbon",
"type": "tidelift"
}
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "composer/pcre",
"version": "3.3.2",
@ -2297,6 +2433,71 @@
],
"time": "2025-03-24T10:02:05+00:00"
},
{
"name": "morilog/jalali",
"version": "v3.4.2",
"source": {
"type": "git",
"url": "https://github.com/morilog/jalali.git",
"reference": "f475f4db7bd540c6abc01126e46824c897ed1e03"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/morilog/jalali/zipball/f475f4db7bd540c6abc01126e46824c897ed1e03",
"reference": "f475f4db7bd540c6abc01126e46824c897ed1e03",
"shasum": ""
},
"require": {
"beberlei/assert": "^3.0",
"nesbot/carbon": "^1.21 || ^2.0 || ^3.0",
"php": "^7.0 | ^8.0"
},
"require-dev": {
"phpunit/phpunit": ">4.0"
},
"type": "library",
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Morilog\\Jalali\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Milad Rey",
"email": "miladr@gmail.com"
},
{
"name": "Morteza Parvini",
"email": "m.parvini@outlook.com"
}
],
"description": "This Package helps developers to easily work with Jalali (Shamsi or Iranian) dates in PHP applications, based on Jalali (Shamsi) DateTime class.",
"keywords": [
"Jalali",
"date",
"datetime",
"laravel",
"morilog"
],
"support": {
"issues": "https://github.com/morilog/jalali/issues",
"source": "https://github.com/morilog/jalali/tree/v3.4.2"
},
"funding": [
{
"url": "https://issuehunt.io/r/morilog",
"type": "issuehunt"
}
],
"time": "2024-05-09T08:44:51+00:00"
},
{
"name": "mpdf/mpdf",
"version": "v8.2.5",
@ -2714,6 +2915,111 @@
},
"time": "2024-06-24T21:25:28+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.10.2",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon.git",
"reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24",
"reference": "76b5c07b8a9d2025ed1610e14cef1f3fd6ad2c24",
"shasum": ""
},
"require": {
"carbonphp/carbon-doctrine-types": "<100.0",
"ext-json": "*",
"php": "^8.1",
"psr/clock": "^1.0",
"symfony/clock": "^6.3.12 || ^7.0",
"symfony/polyfill-mbstring": "^1.0",
"symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0"
},
"provide": {
"psr/clock-implementation": "1.0"
},
"require-dev": {
"doctrine/dbal": "^3.6.3 || ^4.0",
"doctrine/orm": "^2.15.2 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.75.0",
"kylekatarnls/multi-tester": "^2.5.3",
"phpmd/phpmd": "^2.15.0",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.17",
"phpunit/phpunit": "^10.5.46",
"squizlabs/php_codesniffer": "^3.13.0"
},
"bin": [
"bin/carbon"
],
"type": "library",
"extra": {
"laravel": {
"providers": [
"Carbon\\Laravel\\ServiceProvider"
]
},
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-2.x": "2.x-dev",
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Carbon\\": "src/Carbon/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Brian Nesbitt",
"email": "brian@nesbot.com",
"homepage": "https://markido.com"
},
{
"name": "kylekatarnls",
"homepage": "https://github.com/kylekatarnls"
}
],
"description": "An API extension for DateTime that supports 281 different languages.",
"homepage": "https://carbon.nesbot.com",
"keywords": [
"date",
"datetime",
"time"
],
"support": {
"docs": "https://carbon.nesbot.com/docs",
"issues": "https://github.com/CarbonPHP/carbon/issues",
"source": "https://github.com/CarbonPHP/carbon"
},
"funding": [
{
"url": "https://github.com/sponsors/kylekatarnls",
"type": "github"
},
{
"url": "https://opencollective.com/Carbon#sponsor",
"type": "opencollective"
},
{
"url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme",
"type": "tidelift"
}
],
"time": "2025-08-02T09:36:06+00:00"
},
{
"name": "nikic/php-parser",
"version": "v5.4.0",

View file

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20241220000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add memberCount field to chat_channel table';
}
public function up(Schema $schema): void
{
// Add memberCount column to chat_channel table
$this->addSql('ALTER TABLE chat_channel ADD member_count INT NOT NULL DEFAULT 0');
// Update existing channels with correct member count
$this->addSql('
UPDATE chat_channel c
SET member_count = (
SELECT COUNT(*)
FROM chat_channel_member m
WHERE m.channel_id = c.id AND m.is_active = 1
)
');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chat_channel DROP member_count');
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250113000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add approval fields to HesabdariDoc and HesabdariRow tables';
}
public function up(Schema $schema): void
{
// Add approval fields to HesabdariDoc table
$this->addSql('ALTER TABLE hesabdari_doc ADD is_preview TINYINT(1) DEFAULT NULL');
$this->addSql('ALTER TABLE hesabdari_doc ADD is_approved TINYINT(1) DEFAULT NULL');
$this->addSql('ALTER TABLE hesabdari_doc ADD approved_by_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE hesabdari_doc ADD CONSTRAINT FK_HESABDARI_DOC_APPROVED_BY FOREIGN KEY (approved_by_id) REFERENCES user (id)');
$this->addSql('CREATE INDEX IDX_HESABDARI_DOC_APPROVED_BY ON hesabdari_doc (approved_by_id)');
// Set default values for existing documents
$this->addSql('UPDATE hesabdari_doc SET is_preview = 0, is_approved = 1 WHERE is_preview IS NULL');
}
public function down(Schema $schema): void
{
// Remove approval fields from HesabdariDoc table
$this->addSql('ALTER TABLE hesabdari_doc DROP FOREIGN KEY FK_HESABDARI_DOC_APPROVED_BY');
$this->addSql('DROP INDEX IDX_HESABDARI_DOC_APPROVED_BY ON hesabdari_doc');
$this->addSql('ALTER TABLE hesabdari_doc DROP is_preview, DROP is_approved, DROP approved_by_id');
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250113000001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remove status field from StoreroomTicket table';
}
public function up(Schema $schema): void
{
// Remove status field from storeroom_ticket table
$this->addSql('ALTER TABLE storeroom_ticket DROP status');
}
public function down(Schema $schema): void
{
// Add status field back to storeroom_ticket table
$this->addSql('ALTER TABLE storeroom_ticket ADD status VARCHAR(50) DEFAULT NULL');
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250113000002 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add approval fields to StoreroomTicket table';
}
public function up(Schema $schema): void
{
// Add approval fields to storeroom_ticket table
$this->addSql('ALTER TABLE storeroom_ticket ADD is_preview TINYINT(1) DEFAULT NULL');
$this->addSql('ALTER TABLE storeroom_ticket ADD is_approved TINYINT(1) DEFAULT NULL');
$this->addSql('ALTER TABLE storeroom_ticket ADD approved_by_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE storeroom_ticket ADD CONSTRAINT FK_STOREROOM_TICKET_APPROVED_BY FOREIGN KEY (approved_by_id) REFERENCES user (id)');
$this->addSql('CREATE INDEX IDX_STOREROOM_TICKET_APPROVED_BY ON storeroom_ticket (approved_by_id)');
// Set default values for existing tickets
$this->addSql('UPDATE storeroom_ticket SET is_preview = 0, is_approved = 1 WHERE is_preview IS NULL');
}
public function down(Schema $schema): void
{
// Remove approval fields from storeroom_ticket table
$this->addSql('ALTER TABLE storeroom_ticket DROP FOREIGN KEY FK_STOREROOM_TICKET_APPROVED_BY');
$this->addSql('DROP INDEX IDX_STOREROOM_TICKET_APPROVED_BY ON storeroom_ticket');
$this->addSql('ALTER TABLE storeroom_ticket DROP is_preview, DROP is_approved, DROP approved_by_id');
}
}

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250809100001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create custom_invoice_template table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE custom_invoice_template (id INT AUTO_INCREMENT NOT NULL, bid_id INT NOT NULL, submitter_id INT NOT NULL, name VARCHAR(255) NOT NULL, is_public TINYINT(1) NOT NULL, code LONGTEXT NOT NULL, INDEX IDX_CUSTOM_INV_TPL_BID (bid_id), INDEX IDX_CUSTOM_INV_TPL_SUBMITTER (submitter_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE custom_invoice_template ADD CONSTRAINT FK_CUSTOM_INV_TPL_BID FOREIGN KEY (bid_id) REFERENCES business (id)');
$this->addSql('ALTER TABLE custom_invoice_template ADD CONSTRAINT FK_CUSTOM_INV_TPL_SUBMITTER FOREIGN KEY (submitter_id) REFERENCES `user` (id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE custom_invoice_template DROP FOREIGN KEY FK_CUSTOM_INV_TPL_BID');
$this->addSql('ALTER TABLE custom_invoice_template DROP FOREIGN KEY FK_CUSTOM_INV_TPL_SUBMITTER');
$this->addSql('DROP TABLE custom_invoice_template');
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250811093832 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250811101253 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250811120010 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add warranty usage columns to plug_warranty_serial table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE plug_warranty_serial ADD used TINYINT(1) DEFAULT NULL');
$this->addSql('ALTER TABLE plug_warranty_serial ADD used_at VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE plug_warranty_serial ADD used_ticket_code VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE plug_warranty_serial DROP used');
$this->addSql('ALTER TABLE plug_warranty_serial DROP used_at');
$this->addSql('ALTER TABLE plug_warranty_serial DROP used_ticket_code');
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250811123020 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add status and importWorkflowCode to storeroom_ticket';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE storeroom_ticket ADD status VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE storeroom_ticket ADD import_workflow_code VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE storeroom_ticket DROP status');
$this->addSql('ALTER TABLE storeroom_ticket DROP import_workflow_code');
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250811124530 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add two-step approval flags to permission table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE permission ADD require_two_step_sell TINYINT(1) DEFAULT NULL');
$this->addSql('ALTER TABLE permission ADD require_two_step_payment TINYINT(1) DEFAULT NULL');
$this->addSql('ALTER TABLE permission ADD require_two_step_store TINYINT(1) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE permission DROP require_two_step_sell');
$this->addSql('ALTER TABLE permission DROP require_two_step_payment');
$this->addSql('ALTER TABLE permission DROP require_two_step_store');
}
}

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250815143325 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE hesabdari_row DROP FOREIGN KEY FK_83B2C6EC2D234F6A
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_83B2C6EC2D234F6A ON hesabdari_row
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE hesabdari_row DROP is_preview, DROP is_approved, DROP approved_by_id
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial DROP FOREIGN KEY FK_1A5DC26F4D9866B8
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_1A5DC26F4D9866B8 ON plug_warranty_serial
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial ADD allocated_to_document_id INT DEFAULT NULL, ADD allocated_at DATETIME DEFAULT NULL, ADD bound_to_item_id INT DEFAULT NULL, ADD bound_at DATETIME DEFAULT NULL, DROP used, DROP used_at, CHANGE date_submit date_submit DATETIME NOT NULL, CHANGE description description LONGTEXT DEFAULT NULL, CHANGE warranty_start_date warranty_start_date DATETIME DEFAULT NULL, CHANGE warranty_end_date warranty_end_date DATETIME DEFAULT NULL, CHANGE status status VARCHAR(20) NOT NULL, CHANGE notes notes LONGTEXT DEFAULT NULL, CHANGE used_ticket_code void_reason VARCHAR(255) DEFAULT NULL, CHANGE bid_id business_id INT NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial ADD CONSTRAINT FK_1A5DC26FA89DB457 FOREIGN KEY (business_id) REFERENCES business (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_1A5DC26FA89DB457 ON plug_warranty_serial (business_id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_status_product ON plug_warranty_serial (status, commodity_id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_alloc_doc ON plug_warranty_serial (allocated_to_document_id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial RENAME INDEX uniq_1a5dc26fd948ee2 TO uniq_warranty_serial
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE storeroom_ticket RENAME INDEX idx_storeroom_ticket_approved_by TO IDX_9B4CC0F72D234F6A
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE hesabdari_row ADD is_preview TINYINT(1) DEFAULT NULL, ADD is_approved TINYINT(1) DEFAULT NULL, ADD approved_by_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE hesabdari_row ADD CONSTRAINT FK_83B2C6EC2D234F6A FOREIGN KEY (approved_by_id) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE NO ACTION
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_83B2C6EC2D234F6A ON hesabdari_row (approved_by_id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial DROP FOREIGN KEY FK_1A5DC26FA89DB457
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_1A5DC26FA89DB457 ON plug_warranty_serial
SQL);
$this->addSql(<<<'SQL'
DROP INDEX idx_status_product ON plug_warranty_serial
SQL);
$this->addSql(<<<'SQL'
DROP INDEX idx_alloc_doc ON plug_warranty_serial
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial ADD used TINYINT(1) DEFAULT NULL, ADD used_at VARCHAR(50) DEFAULT NULL, DROP allocated_to_document_id, DROP allocated_at, DROP bound_to_item_id, DROP bound_at, CHANGE date_submit date_submit VARCHAR(25) NOT NULL, CHANGE description description VARCHAR(255) DEFAULT NULL, CHANGE warranty_start_date warranty_start_date VARCHAR(25) DEFAULT NULL, CHANGE warranty_end_date warranty_end_date VARCHAR(25) DEFAULT NULL, CHANGE status status VARCHAR(50) DEFAULT NULL, CHANGE notes notes VARCHAR(255) DEFAULT NULL, CHANGE business_id bid_id INT NOT NULL, CHANGE void_reason used_ticket_code VARCHAR(255) DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial ADD CONSTRAINT FK_1A5DC26F4D9866B8 FOREIGN KEY (bid_id) REFERENCES business (id) ON UPDATE NO ACTION ON DELETE NO ACTION
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_1A5DC26F4D9866B8 ON plug_warranty_serial (bid_id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial RENAME INDEX uniq_warranty_serial TO UNIQ_1A5DC26FD948EE2
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE storeroom_ticket RENAME INDEX idx_9b4cc0f72d234f6a TO IDX_STOREROOM_TICKET_APPROVED_BY
SQL);
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250816171207 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial ADD commodity_serial VARCHAR(255) DEFAULT NULL, ADD buyer_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial ADD CONSTRAINT FK_1A5DC26F6C755722 FOREIGN KEY (buyer_id) REFERENCES person (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_1A5DC26F6C755722 ON plug_warranty_serial (buyer_id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial DROP FOREIGN KEY FK_1A5DC26F6C755722
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_1A5DC26F6C755722 ON plug_warranty_serial
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial DROP commodity_serial, DROP buyer_id
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250816185111 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial ADD activation VARCHAR(20) NOT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial DROP activation
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250816185556 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial ADD activation_at VARCHAR(20) NOT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial DROP activation_at
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250818042052 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial CHANGE activation_at activation_at DATETIME DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial CHANGE activation_at activation_at VARCHAR(20) NOT NULL
SQL);
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250818042232 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial CHANGE activation_at activation_at DATETIME DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial CHANGE activation_at activation_at VARCHAR(20) DEFAULT NULL
SQL);
}
}

View file

@ -288,6 +288,7 @@ class PersonService
if (isset($params['shenasemeli'])) $person->setShenasemeli($params['shenasemeli']);
if (isset($params['company'])) $person->setCompany($params['company']);
if (isset($params['tags'])) $person->setTags($params['tags']);
if (array_key_exists('prelabel', $params)) {
if ($params['prelabel'] != '') {
$prelabel = $em->getRepository(\App\Entity\PersonPrelabel::class)->findOneBy(['label' => $params['prelabel']]);

View file

@ -0,0 +1,426 @@
<?php
namespace App\Controller;
use App\Entity\Business;
use App\Entity\HesabdariDoc;
use App\Entity\HesabdariRow;
use App\Entity\Log;
use App\Entity\Permission;
use App\Entity\User;
use App\Service\Access;
use App\Service\Log as LogService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
class ApprovalController extends AbstractController
{
// تأیید حواله انبار
#[Route('/api/approval/approve/storeroom/{ticketCode}', name: 'api_approval_approve_storeroom', methods: ['POST'])]
public function approveStoreroomTicket(
$ticketCode,
#[CurrentUser] ?User $user,
Access $access,
LogService $logService,
EntityManagerInterface $entityManager
): Response {
try {
// بررسی دسترسی کاربر
$acc = $access->hasRole('settings');
if (!$acc) {
throw $this->createAccessDeniedException();
}
$business = $acc['bid'];
$businessSettings = $entityManager->getRepository(Business::class)->find($business->getId());
// بررسی اینکه آیا تأیید دو مرحله‌ای فعال است
if (!$businessSettings->isRequireTwoStepApproval()) {
return $this->json(['success' => false, 'message' => 'تأیید دو مرحله‌ای فعال نیست']);
}
// پیدا کردن حواله انبار
$ticket = $entityManager->getRepository(\App\Entity\StoreroomTicket::class)->findOneBy([
'code' => $ticketCode,
'bid' => $business
]);
if (!$ticket) {
return $this->json(['success' => false, 'message' => 'حواله انبار یافت نشد']);
}
// بررسی مجوز تأیید
$canApprove = $this->canUserApproveStoreroomTicket($user, $businessSettings);
if (!$canApprove) {
return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این حواله را ندارید']);
}
// تأیید حواله
$ticket->setIsPreview(false);
$ticket->setIsApproved(true);
$ticket->setApprovedBy($user);
$entityManager->persist($ticket);
$entityManager->flush();
// ثبت لاگ
$logService->insert(
'تأیید حواله انبار',
"حواله انبار {$ticket->getCode()} توسط {$user->getFullName()} تأیید شد",
$user,
$business
);
return $this->json([
'success' => true,
'message' => 'حواله انبار با موفقیت تأیید شد'
]);
} catch (\Exception $e) {
return $this->json([
'success' => false,
'message' => 'خطا در تأیید حواله انبار: ' . $e->getMessage()
], 500);
}
}
// تأیید فاکتور فروش
#[Route('/api/approval/approve/sales/{docId}', name: 'api_approval_approve_sales', methods: ['POST'])]
public function approveSalesInvoice(
$docId,
#[CurrentUser] ?User $user,
Access $access,
LogService $logService,
EntityManagerInterface $entityManager
): Response {
try {
// بررسی دسترسی کاربر
$acc = $access->hasRole('settings');
if (!$acc) {
throw $this->createAccessDeniedException();
}
$business = $acc['bid'];
$businessSettings = $entityManager->getRepository(Business::class)->find($business->getId());
// بررسی اینکه آیا تأیید دو مرحله‌ای فعال است
if (!$businessSettings->isRequireTwoStepApproval()) {
return $this->json(['success' => false, 'message' => 'تأیید دو مرحله‌ای فعال نیست']);
}
// پیدا کردن فاکتور فروش
$document = $entityManager->getRepository(HesabdariDoc::class)->findOneBy([
'code' => $docId,
'bid' => $business
]);
if (!$document) {
return $this->json(['success' => false, 'message' => 'فاکتور فروش یافت نشد']);
}
// بررسی مجوز تأیید
$canApprove = $this->canUserApproveSalesInvoice($user, $businessSettings);
if (!$canApprove) {
return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این فاکتور را ندارید']);
}
// تأیید فاکتور
$document->setIsPreview(false);
$document->setIsApproved(true);
$document->setApprovedBy($user);
$entityManager->persist($document);
$entityManager->flush();
// ثبت لاگ
$logService->insert(
'تأیید فاکتور فروش',
"فاکتور فروش {$document->getCode()} توسط {$user->getFullName()} تأیید شد",
$user,
$business
);
return $this->json([
'success' => true,
'message' => 'فاکتور فروش با موفقیت تأیید شد'
]);
} catch (\Exception $e) {
return $this->json([
'success' => false,
'message' => 'خطا در تأیید فاکتور فروش: ' . $e->getMessage()
], 500);
}
}
// تأیید سند مالی
#[Route('/api/approval/approve/financial/{docId}', name: 'api_approval_approve_financial', methods: ['POST'])]
public function approveFinancialDocument(
$docId,
#[CurrentUser] ?User $user,
Access $access,
LogService $logService,
EntityManagerInterface $entityManager
): Response {
try {
// بررسی دسترسی کاربر
$acc = $access->hasRole('hasRole');
if (!$acc) {
throw $this->createAccessDeniedException();
}
$business = $acc['bid'];
$businessSettings = $entityManager->getRepository(Business::class)->find($business->getId());
// بررسی اینکه آیا تأیید دو مرحله‌ای فعال است
if (!$businessSettings->isRequireTwoStepApproval()) {
return $this->json(['success' => false, 'message' => 'تأیید دو مرحله‌ای فعال نیست']);
}
// پیدا کردن سند مالی
$document = $entityManager->getRepository(HesabdariDoc::class)->findOneBy([
'id' => $docId,
'bid' => $business
]);
if (!$document) {
return $this->json(['success' => false, 'message' => 'سند مالی یافت نشد']);
}
// بررسی مجوز تأیید
$canApprove = $this->canUserApproveFinancialDocument($user, $businessSettings);
if (!$canApprove) {
return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این سند را ندارید']);
}
// تأیید سند
$document->setIsPreview(false);
$document->setIsApproved(true);
$document->setApprovedBy($user);
$entityManager->persist($document);
$entityManager->flush();
// ثبت لاگ
$logService->insert(
'تأیید سند مالی',
"سند مالی {$document->getCode()} توسط {$user->getFullName()} تأیید شد",
$user,
$business
);
return $this->json([
'success' => true,
'message' => 'سند مالی با موفقیت تأیید شد'
]);
} catch (\Exception $e) {
return $this->json([
'success' => false,
'message' => 'خطا در تأیید سند مالی: ' . $e->getMessage()
], 500);
}
}
#[Route('/api/approval/reject/{docId}', name: 'api_approval_reject', methods: ['POST'])]
public function rejectDocument(
$docId,
Request $request,
#[CurrentUser] ?User $user,
Access $access,
LogService $logService,
EntityManagerInterface $entityManager
): Response {
try {
// بررسی دسترسی کاربر
$acc = $access->hasRole('owner');
if (!$acc) {
throw $this->createAccessDeniedException();
}
$business = $acc['bid'];
$businessSettings = $entityManager->getRepository(Business::class)->find($business->getId());
// بررسی اینکه آیا تأیید دو مرحله‌ای فعال است
if (!$businessSettings->isRequireTwoStepApproval()) {
return $this->json(['success' => false, 'message' => 'تأیید دو مرحله‌ای فعال نیست']);
}
// پیدا کردن سند
$document = $entityManager->getRepository(HesabdariDoc::class)->findOneBy([
'id' => $docId,
'bid' => $business
]);
if (!$document) {
return $this->json(['success' => false, 'message' => 'سند یافت نشد']);
}
// دریافت دلیل رد
$data = json_decode($request->getContent(), true);
$rejectionReason = $data['reason'] ?? 'دلیل مشخص نشده';
// رد سند
$document->setIsPreview(false);
$document->setIsApproved(false);
$document->setApprovedBy(null);
// ردیف‌ها نیازی به تنظیم جداگانه ندارند - از سند پیروی می‌کنند
$entityManager->persist($document);
$entityManager->flush();
// ثبت لاگ
$logService->insert(
'رد سند',
"سند {$document->getCode()} توسط {$user->getFullName()} رد شد. دلیل: {$rejectionReason}",
$user,
$business
);
return $this->json([
'success' => true,
'message' => 'سند با موفقیت رد شد'
]);
} catch (\Exception $e) {
return $this->json([
'success' => false,
'message' => 'خطا در رد سند: ' . $e->getMessage()
], 500);
}
}
#[Route('/api/approval/check-permission/{docId}', name: 'api_approval_check_permission', methods: ['GET'])]
public function checkApprovalPermission(
$docId,
#[CurrentUser] ?User $user,
Access $access,
EntityManagerInterface $entityManager
): Response {
try {
$acc = $access->hasRole('settings');
if (!$acc) {
return $this->json(['canApprove' => false, 'message' => 'دسترسی محدود']);
}
$business = $acc['bid'];
$businessSettings = $entityManager->getRepository(Business::class)->find($business->getId());
// پیدا کردن سند
$document = $entityManager->getRepository(HesabdariDoc::class)->findOneBy([
'id' => $docId,
'bid' => $business
]);
if (!$document) {
return $this->json(['canApprove' => false, 'message' => 'سند یافت نشد']);
}
$canApprove = $this->canUserApproveDocument($user, $businessSettings, $document);
return $this->json([
'canApprove' => $canApprove,
'documentStatus' => [
'isPreview' => $document->isPreview(),
'isApproved' => $document->isApproved(),
'approvedBy' => $document->getApprovedBy() ? $document->getApprovedBy()->getFullName() : null
]
]);
} catch (\Exception $e) {
return $this->json([
'canApprove' => false,
'message' => 'خطا در بررسی مجوز: ' . $e->getMessage()
], 500);
}
}
/**
* بررسی اینکه آیا کاربر می‌تواند سند را تأیید کند
*/
private function canUserApproveDocument(User $user, Business $business, HesabdariDoc $document): bool
{
// مدیر کسب و کار همیشه می‌تواند تأیید کند
if ($user->getEmail() === $business->getOwner()->getEmail()) {
return true;
}
// بررسی تأییدکنندگان اختصاصی بر اساس نوع سند
$documentType = $this->getDocumentType($document);
switch ($documentType) {
case 'invoice':
return $business->getInvoiceApprover() === $user->getEmail();
case 'warehouse':
return $business->getWarehouseApprover() === $user->getEmail();
case 'financial':
return $business->getFinancialApprover() === $user->getEmail();
default:
return false;
}
}
/**
* تشخیص نوع سند
*/
private function getDocumentType(HesabdariDoc $document): string
{
$type = $document->getType();
if (strpos($type, 'sell') !== false || strpos($type, 'invoice') !== false) {
return 'invoice';
}
if (strpos($type, 'warehouse') !== false || strpos($type, 'storeroom') !== false) {
return 'warehouse';
}
if (strpos($type, 'payment') !== false || strpos($type, 'receipt') !== false || strpos($type, 'hesabdari') !== false) {
return 'financial';
}
return 'unknown';
}
// بررسی مجوز تأیید حواله انبار
private function canUserApproveStoreroomTicket(User $user, Business $business): bool
{
// مدیر کسب و کار همیشه می‌تواند تأیید کند
if ($user->getEmail() === $business->getOwner()->getEmail()) {
return true;
}
// کاربر تأییدکننده انبار
return $business->getWarehouseApprover() === $user->getEmail();
}
// بررسی مجوز تأیید فاکتور فروش
private function canUserApproveSalesInvoice(User $user, Business $business): bool
{
// مدیر کسب و کار همیشه می‌تواند تأیید کند
if ($user->getEmail() === $business->getOwner()->getEmail()) {
return true;
}
// کاربر تأییدکننده فاکتور فروش
return $business->getInvoiceApprover() === $user->getEmail();
}
// بررسی مجوز تأیید سند مالی
private function canUserApproveFinancialDocument(User $user, Business $business): bool
{
// مدیر کسب و کار همیشه می‌تواند تأیید کند
if ($user->getEmail() === $business->getOwner()->getEmail()) {
return true;
}
// کاربر تأییدکننده اسناد مالی
return $business->getFinancialApprover() === $user->getEmail();
}
}

View file

@ -246,6 +246,22 @@ class BusinessController extends AbstractController
$business->setWalletEnable(false);
}
}
if (array_key_exists('requireTwoStepApproval', $params)) {
$business->setRequireTwoStepApproval((bool)$params['requireTwoStepApproval']);
}
// Set approvers
if (array_key_exists('invoiceApprover', $params)) {
$business->setInvoiceApprover($params['invoiceApprover']);
}
if (array_key_exists('warehouseApprover', $params)) {
$business->setWarehouseApprover($params['warehouseApprover']);
}
if (array_key_exists('financialApprover', $params)) {
$business->setFinancialApprover($params['financialApprover']);
}
//get Money type
if (!array_key_exists('arzmain', $params) && $isNew) {
@ -548,6 +564,8 @@ class BusinessController extends AbstractController
'plugWarranty' => true,
'inquiry' => true,
'ai' => true,
'warehouseManager' => true,
'importWorkflow' => true,
];
} elseif ($perm) {
$result = [
@ -595,7 +613,16 @@ class BusinessController extends AbstractController
'plugWarranty' => $perm->isPlugWarrantyManager(),
'inquiry' => $perm->isInquiry(),
'ai' => $perm->isAi(),
'warehouseManager' => $perm->isWarehouseManager(),
'importWorkflow' => $perm->isImportWorkflow(),
];
if ($perm->isWarehouseManager()) {
$result['commodity'] = true;
$result['store'] = true;
$result['plugWarranty'] = true;
$result['permission'] = true;
}
}
return $this->json($result);
}
@ -668,6 +695,8 @@ class BusinessController extends AbstractController
$perm->setPlugTaxSettings($params['plugTaxSettings']);
$perm->setInquiry($params['inquiry']);
$perm->setAi($params['ai']);
$perm->setWarehouseManager($params['warehouseManager'] ?? false);
$perm->setImportWorkflow($params['importWorkflow'] ?? false);
$entityManager->persist($perm);
$entityManager->flush();
$log->insert('تنظیمات پایه', 'ویرایش دسترسی‌های کاربر با پست الکترونیکی ' . $user->getEmail(), $this->getUser(), $business);

File diff suppressed because it is too large Load diff

View file

@ -479,15 +479,24 @@ class PluginController extends AbstractController
'icon' => ' taxplugin.jpg',
'defaultOn' => null,
],
// [
// 'name' => 'مدیریت گارانتی',
// 'code' => 'warranty',
// 'timestamp' => '32104000',
// 'timelabel' => 'یک سال',
// 'price' => '200000',
// 'icon' => 'warranty.png',
// 'defaultOn' => null,
// ],
[
'name' => 'مدیریت گارانتی',
'code' => 'warranty',
'timestamp' => '32104000',
'timelabel' => 'یک سال',
'price' => '200000',
'icon' => 'warranty.png',
'defaultOn' => null,
],
[
'name' => 'مدیریت واردات کالا',
'code' => 'import-workflow',
'timestamp' => '32104000',
'timelabel' => 'یک سال',
'price' => '200000',
'icon' => 'import-workflow.png',
'defaultOn' => null,
],
];
$repo = $entityManager->getRepository(PluginProdect::class);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,400 @@
<?php
namespace App\Controller;
use App\Entity\Business;
use App\Entity\PlugWarrantySerial;
use App\Service\PluginService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Morilog\Jalali\CalendarUtils;
use App\Service\registryMGR;
class PublicController extends AbstractController
{
/**
* Check if warranty plugin is active for the business
*/
private function checkWarrantyPluginActive(Business $business, PluginService $pluginService): JsonResponse|null
{
if (!$pluginService->isActive('warranty', $business)) {
return $this->json([
'success' => false,
'message' => 'پلاگین گارانتی برای این کسب و کار فعال نیست',
'error' => 'PLUGIN_NOT_ACTIVE'
], 404);
}
return null;
}
#[Route('/api/public/{businessId}/warranty/check/{code}', name: 'api_public_warranty_check', methods: ['GET'])]
public function checkWarrantyCode(string $businessId, string $code, EntityManagerInterface $entityManager, PluginService $pluginService, registryMGR $registryMGR): JsonResponse
{
// Validate input
if (empty($code)) {
return $this->json([
'success' => false,
'message' => 'کد گارانتی وارد نشده است'
], 400);
}
// Find business
$business = $entityManager->getRepository(Business::class)->find($businessId);
if (!$business) {
return $this->json([
'success' => false,
'message' => 'کسب و کار مورد نظر یافت نشد'
], 404);
}
// Check if warranty plugin is active
$pluginCheck = $this->checkWarrantyPluginActive($business, $pluginService);
if ($pluginCheck) {
return $pluginCheck;
}
// Find warranty serial
$warrantySerial = $entityManager->getRepository(PlugWarrantySerial::class)->findOneBy([
'business' => $business,
'serialNumber' => $code
]);
if (!$warrantySerial) {
return $this->json([
'success' => false,
'message' => 'کد گارانتی نامعتبر است یا متعلق به این کسب و کار نیست'
], 404);
}
// Check if already activated (used)
if (!$warrantySerial->isUsed()) {
return $this->json([
'success' => false,
'message' => 'این گارانتی ثبت نشده است',
], 400);
}
// if ($warrantySerial->getActivation() === 'active') {
// return $this->json([
// 'success' => false,
// 'message' => 'این گارانتی قبلاً فعال شده است',
// ], 400);
// }
$activationTimeLimit = (int) ($registryMGR->get('warranty', 'activationGraceDays') ?? 7);
$allocatedAt = $warrantySerial->getAllocatedAt();
$currentDate = new \DateTime();
$daysDiff = $currentDate->diff($allocatedAt)->days;
if ($daysDiff > $activationTimeLimit) {
return $this->json([
'success' => false,
'message' => "مهلت فعال‌سازی این گارانتی ({$activationTimeLimit} روز) به پایان رسیده است"
], 400);
}
// Get commodity information
$commodity = $warrantySerial->getCommodity();
// Calculate warranty period end date
$warrantyEndDate = null;
if ($warrantySerial->getWarrantyEndDate()) {
$warrantyEndDate = $warrantySerial->getWarrantyEndDate();
} elseif ($warrantySerial->getWarrantyStartDate()) {
// If only start date is set, assume 12 months warranty period
$startDate = new \DateTime($warrantySerial->getWarrantyStartDate());
$endDate = $startDate->add(new \DateInterval('P12M'));
$warrantyEndDate = $endDate->format('Y-m-d');
}
// Prepare product information
$productInfo = [
'serialNumber' => $warrantySerial->getSerialNumber(),
'commoditySerial' => $warrantySerial->getCommoditySerial(),
'productName' => $commodity ? $commodity->getName() : 'نامشخص',
'productCode' => $commodity ? $commodity->getCode() : null,
'description' => $warrantySerial->getDescription(),
'submitDate' => $warrantySerial->getDateSubmit(),
'warrantyStartDate' => $warrantySerial->getWarrantyStartDate(),
'warrantyEndDate' => $warrantyEndDate,
'status' => $warrantySerial->getStatus(),
'activation' => $warrantySerial->getActivation(),
'notes' => $warrantySerial->getNotes(),
'activationTimeLimit' => $activationTimeLimit,
'daysRemaining' => max(0, $activationTimeLimit - $daysDiff),
'submitter' => $warrantySerial->getSubmitter() ? $warrantySerial->getSubmitter()->getFullName() : null,
'businessName' => $business->getName(),
'activationTicketCode' => $warrantySerial->getActivationTicketCode(),
'requireActivationSecret' => true
];
return $this->json([
'success' => true,
'data' => $productInfo
]);
}
#[Route('/api/public/{businessId}/warranty/activate/{code}', name: 'api_public_warranty_activate', methods: ['POST'])]
public function activateWarranty(string $businessId, string $code, Request $request, EntityManagerInterface $entityManager, PluginService $pluginService, registryMGR $registryMGR): JsonResponse
{
// Validate input
if (empty($code)) {
return $this->json([
'success' => false,
'message' => 'کد گارانتی وارد نشده است'
], 400);
}
// Find business
$business = $entityManager->getRepository(Business::class)->find($businessId);
if (!$business) {
return $this->json([
'success' => false,
'message' => 'کسب و کار مورد نظر یافت نشد'
], 404);
}
// Check if warranty plugin is active
$pluginCheck = $this->checkWarrantyPluginActive($business, $pluginService);
if ($pluginCheck) {
return $pluginCheck;
}
// Find warranty serial
$warrantySerial = $entityManager->getRepository(PlugWarrantySerial::class)->findOneBy([
'business' => $business,
'serialNumber' => $code
]);
if (!$warrantySerial) {
return $this->json([
'success' => false,
'message' => 'کد گارانتی نامعتبر است یا متعلق به این کسب و کار نیست'
], 404);
}
// Check if already activated
if (!$warrantySerial->isUsed()) {
return $this->json([
'success' => false,
'message' => 'این گارانتی ثبت نشده است',
], 400);
}
// Check status
if ($warrantySerial->getActivation() !== 'deactive') {
return $this->json([
'success' => false,
'message' => 'وضعیت این گارانتی فعال است'
], 400);
}
$activationTimeLimit = (int) ($registryMGR->get('warranty', 'activationGraceDays') ?? 7);
$allocatedAt = $warrantySerial->getAllocatedAt();
$currentDate = new \DateTime();
$daysDiff = $currentDate->diff($allocatedAt)->days;
if ($daysDiff > $activationTimeLimit) {
return $this->json([
'success' => false,
'message' => "مهلت فعال‌سازی این گارانتی ({$activationTimeLimit} روز) به پایان رسیده است"
], 400);
}
$secret = json_decode($request->getContent() ?: '{}', true)['activationSecret'] ?? '';
if ($warrantySerial->getActivationTicketSecret()) {
if (!$secret || $secret !== $warrantySerial->getActivationTicketSecret()) {
return $this->json([
'success' => false,
'message' => 'کد فعال‌سازی حواله نامعتبر است'
], 400);
}
}
$warrantySerial->setActivation('active');
$warrantySerial->setActivationAt(new \DateTimeImmutable());
// Set warranty start date to current date if not already set
// if (!$warrantySerial->getWarrantyStartDate()) {
// $warrantySerial->setWarrantyStartDate(date('Y-m-d'));
// }
// Set warranty end date if not already set (default 12 months)
// if (!$warrantySerial->getWarrantyEndDate()) {
// $endDate = new \DateTime();
// $endDate->add(new \DateInterval('P12M'));
// $warrantySerial->setWarrantyEndDate($endDate->format('Y-m-d'));
// }
// Save changes
$entityManager->persist($warrantySerial);
$entityManager->flush();
// Get commodity information for response
$commodity = $warrantySerial->getCommodity();
// Prepare activation information
$activationInfo = [
'serialNumber' => $warrantySerial->getSerialNumber(),
'productName' => $commodity ? $commodity->getName() : 'نامشخص',
'productCode' => $commodity ? $commodity->getCode() : null,
'activationDate' => $warrantySerial->getActivationAt()?->format('Y-m-d'),
'warrantyStartDate' => $warrantySerial->getWarrantyStartDate() ? jalaliToGregorian($warrantySerial->getWarrantyStartDate()->format('Y/m/d')) : null,
'warrantyEndDate' => $warrantySerial->getWarrantyEndDate() ? jalaliToGregorian($warrantySerial->getWarrantyEndDate()->format('Y/m/d')) : null,
'businessName' => $business->getName(),
'notes' => $warrantySerial->getNotes()
];
return $this->json([
'success' => true,
'message' => 'گارانتی با موفقیت فعال شد',
'data' => $activationInfo
]);
}
#[Route('/api/public/{businessId}/warranty/help', name: 'api_public_warranty_help', methods: ['GET'])]
public function getWarrantyHelp(string $businessId, PluginService $pluginService, EntityManagerInterface $entityManager): JsonResponse
{
// Find business
$business = $entityManager->getRepository(Business::class)->find($businessId);
if (!$business) {
return $this->json([
'success' => false,
'message' => 'کسب و کار مورد نظر یافت نشد'
], 404);
}
// Check if warranty plugin is active
$pluginCheck = $this->checkWarrantyPluginActive($business, $pluginService);
if ($pluginCheck) {
return $pluginCheck;
}
$helpInfo = [
'codeFormat' => [
'prefix' => 'WR',
'length' => 11,
'example' => 'WR-123456789',
'description' => 'کد گارانتی با حروف WR شروع می‌شود و شامل 9 رقم است'
],
'locations' => [
'محصول' => 'روی برچسب چسبیده شده به محصول',
'فاکتور' => 'در فاکتور خرید یا رسید پرداخت',
'بسته‌بندی' => 'در جعبه یا بسته‌بندی محصول',
'ایمیل' => 'در ایمیل تأیید خرید'
],
'activationTimeLimit' => [
'default' => 7,
'unit' => 'روز',
'description' => 'گارانتی باید حداکثر 7 روز پس از خرید فعال شود'
],
'support' => [
'phone' => '021-88888888',
'email' => 'support@example.com',
'hours' => 'شنبه تا پنج‌شنبه، 8 صبح تا 8 شب'
]
];
return $this->json([
'success' => true,
'data' => $helpInfo
]);
}
#[Route('/api/public/{businessId}/status', name: 'api_public_business_status', methods: ['GET'])]
public function getBusinessStatus(string $businessId, PluginService $pluginService, EntityManagerInterface $entityManager): JsonResponse
{
// Find business
$business = $entityManager->getRepository(Business::class)->find($businessId);
if (!$business) {
return $this->json([
'success' => false,
'message' => 'کسب و کار مورد نظر یافت نشد',
'error' => 'BUSINESS_NOT_FOUND'
], 404);
}
// Check if warranty plugin is active
if (!$pluginService->isActive('warranty', $business)) {
return $this->json([
'success' => false,
'message' => 'پلاگین گارانتی برای این کسب و کار فعال نیست',
'error' => 'PLUGIN_NOT_ACTIVE'
], 404);
}
return $this->json([
'success' => true,
'message' => 'کسب و کار و پلاگین گارانتی فعال است',
'data' => [
'businessName' => $business->getName(),
'pluginActive' => true
]
]);
}
#[Route('/api/public/{businessId}/warranty/qr-scan', name: 'api_public_warranty_qr_scan', methods: ['POST'])]
public function scanQrCode(string $businessId, Request $request, PluginService $pluginService, EntityManagerInterface $entityManager): JsonResponse
{
// Find business
$business = $entityManager->getRepository(Business::class)->find($businessId);
if (!$business) {
return $this->json([
'success' => false,
'message' => 'کسب و کار مورد نظر یافت نشد'
], 404);
}
// Check if warranty plugin is active
$pluginCheck = $this->checkWarrantyPluginActive($business, $pluginService);
if ($pluginCheck) {
return $pluginCheck;
}
// TODO: Implement QR code scanning logic
$params = json_decode($request->getContent(), true);
$qrData = $params['qrData'] ?? '';
if (empty($qrData)) {
return $this->json([
'success' => false,
'message' => 'داده QR خالی است'
], 400);
}
// Extract warranty code from QR data
// Assuming QR contains warranty code directly or in a URL
$warrantyCode = $this->extractWarrantyCodeFromQr($qrData);
if (!$warrantyCode) {
return $this->json([
'success' => false,
'message' => 'کد گارانتی در QR یافت نشد'
], 400);
}
return $this->json([
'success' => true,
'warrantyCode' => $warrantyCode
]);
}
private function extractWarrantyCodeFromQr(string $qrData): ?string
{
if (preg_match('/WR-\d{9}/', $qrData, $matches)) {
return $matches[0];
}
if (preg_match('/warranty.*code[=\/]([A-Za-z0-9-]+)/', $qrData, $matches)) {
return $matches[1];
}
return null;
}
}
function jalaliToGregorian($date) {
$p = explode('/', $date);
return implode('-', CalendarUtils::toGregorian($p[0], $p[1], $p[2]));
}

View file

@ -68,6 +68,41 @@ class SellController extends AbstractController
]);
}
#[Route('/api/sell/approve/{code}', name: 'app_sell_approve', methods: ['POST'])]
public function approveSellDoc(string $code, Request $request, Access $access, EntityManagerInterface $entityManager): JsonResponse
{
$acc = $access->hasRole('sell');
if (!$acc) throw $this->createAccessDeniedException();
$doc = $entityManager->getRepository(\App\Entity\HesabdariDoc::class)->findOneBy([
'bid' => $acc['bid'],
'code' => $code,
'money' => $acc['money']
]);
if (!$doc) throw $this->createNotFoundException('فاکتور یافت نشد');
$doc->setStatus('approved');
$entityManager->persist($doc);
$entityManager->flush();
return $this->json(['result' => 0]);
}
#[Route('/api/sell/payment/approve/{code}', name: 'app_sell_payment_approve', methods: ['POST'])]
public function approveSellPayment(string $code, Request $request, Access $access, EntityManagerInterface $entityManager): JsonResponse
{
$acc = $access->hasRole('sell');
if (!$acc) throw $this->createAccessDeniedException();
$paymentDoc = $entityManager->getRepository(\App\Entity\HesabdariDoc::class)->findOneBy([
'bid' => $acc['bid'],
'code' => $code,
'money' => $acc['money'],
'type' => 'sell_receive'
]);
if (!$paymentDoc) throw $this->createNotFoundException('سند دریافت یافت نشد');
$paymentDoc->setStatus('approved');
$entityManager->persist($paymentDoc);
$entityManager->flush();
return $this->json(['result' => 0]);
}
#[Route('/api/sell/get/info/{code}', name: 'app_sell_get_info')]
public function app_sell_get_info(Request $request, Access $access, Log $log, EntityManagerInterface $entityManager, string $code): JsonResponse
{
@ -195,6 +230,16 @@ class SellController extends AbstractController
$doc->setSubmitter($this->getUser());
$doc->setMoney($acc['money']);
$doc->setCode($provider->getAccountingCode($acc['bid'], 'accounting'));
// Set approval fields based on business settings
$business = $acc['bid'];
if ($business->isRequireTwoStepApproval()) {
$doc->setIsPreview(true);
$doc->setIsApproved(false);
} else {
$doc->setIsPreview(false);
$doc->setIsApproved(true);
}
}
if ($params['transferCost'] != 0) {
$hesabdariRow = new HesabdariRow();
@ -287,6 +332,18 @@ class SellController extends AbstractController
$hesabdariRow->setPerson($person);
$entityManager->persist($hesabdariRow);
// Two-step approval: اگر کسب‌وکار تأیید دو مرحله‌ای را الزامی کرده باشد
$business = $entityManager->getRepository(\App\Entity\Business::class)->find($acc['bid']);
$businessRequire = $business && method_exists($business, 'isRequireTwoStepApproval') ? (bool)$business->isRequireTwoStepApproval() : false;
if ($businessRequire) {
$doc->setIsPreview(true);
$doc->setIsApproved(false);
$doc->setApprovedBy(null);
} else {
$doc->setIsPreview(false);
$doc->setIsApproved(true);
$doc->setApprovedBy($this->getUser());
}
$entityManager->persist($doc);
$entityManager->flush();
if (!$doc->getShortlink()) {
@ -424,10 +481,13 @@ class SellController extends AbstractController
$queryBuilder = $entityManager->createQueryBuilder()
->select('DISTINCT d.id, d.dateSubmit, d.date, d.type, d.code, d.des, d.amount')
->addSelect('d.isPreview, d.isApproved')
->addSelect('u.fullName as submitter')
->addSelect('approver.fullName as approvedByName, approver.id as approvedById, approver.email as approvedByEmail')
->addSelect('l.code as labelCode, l.label as labelLabel')
->from(HesabdariDoc::class, 'd')
->leftJoin('d.submitter', 'u')
->leftJoin('d.approvedBy', 'approver')
->leftJoin('d.InvoiceLabel', 'l')
->leftJoin('d.hesabdariRows', 'r')
->where('d.bid = :bid')
@ -489,7 +549,9 @@ class SellController extends AbstractController
'plugin' => 'd.plugin',
'refData' => 'd.refData',
'shortlink' => 'd.shortlink',
'status' => 'd.status',
'isPreview' => 'd.isPreview',
'isApproved' => 'd.isApproved',
'approvedBy' => 'd.approvedBy',
'submitter' => 'u.fullName',
'label' => 'l.label', // از InvoiceLabel
];
@ -535,6 +597,13 @@ class SellController extends AbstractController
'code' => $doc['labelCode'],
'label' => $doc['labelLabel']
] : null,
'isPreview' => $doc['isPreview'],
'isApproved' => $doc['isApproved'],
'approvedBy' => $doc['approvedByName'] ? [
'fullName' => $doc['approvedByName'],
'id' => $doc['approvedById'],
'email' => $doc['approvedByEmail']
] : null,
];
$mainRow = $entityManager->getRepository(HesabdariRow::class)
@ -778,6 +847,10 @@ class SellController extends AbstractController
$accountStatus['label'] = 'بدهکار';
$accountStatus['value'] = $bd - $bs;
}
// فقط در صورت تایید نهایی مجاز به چاپ هستیم
if ($doc->getStatus() !== 'approved') {
return $this->json(['result' => -10, 'message' => 'فاکتور هنوز تایید نشده است'], 403);
}
if ($params['pdf'] == true || $params['printers'] == true) {
$note = '';
if ($printSettings) {
@ -1187,6 +1260,19 @@ class SellController extends AbstractController
$hesabdariRow->setPerson($person);
$entityManager->persist($hesabdariRow);
// Two-step approval: اگر کسب‌وکار تأیید دو مرحله‌ای را الزامی کرده باشد
$business = $entityManager->getRepository(\App\Entity\Business::class)->find($acc['bid']);
$businessRequire = $business && method_exists($business, 'isRequireTwoStepApproval') ? (bool)$business->isRequireTwoStepApproval() : false;
if ($businessRequire) {
$doc->setIsPreview(true);
$doc->setIsApproved(false);
$doc->setApprovedBy(null);
} else {
$doc->setIsPreview(false);
$doc->setIsApproved(true);
$doc->setApprovedBy($this->getUser());
}
// ذخیره فاکتور
$entityManager->persist($doc);
$entityManager->flush();
@ -1272,6 +1358,14 @@ class SellController extends AbstractController
$receiveRow->setPerson($person);
$entityManager->persist($receiveRow);
// Two-step approval برای دریافت/پرداخت
// $business = $entityManager->getRepository(\App\Entity\Business::class)->find($acc['bid']);
// $businessRequire = $business && method_exists($business, 'isRequireTwoStepApproval') ? (bool)$business->isRequireTwoStepApproval() : false;
// if ($businessRequire) {
// $paymentDoc->setStatus('pending_approval');
// } else {
// $paymentDoc->setStatus('approved');
// }
$entityManager->persist($paymentDoc);
}
$entityManager->flush();

View file

@ -19,6 +19,8 @@ use App\Service\Log;
use App\Service\PluginService;
use App\Service\registryMGR;
use App\Service\SMS;
use App\Entity\PlugWarrantySerial;
use App\Entity\ArchiveFile;
use Doctrine\ORM\EntityManagerInterface;
use ReflectionException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -54,6 +56,85 @@ class StoreroomController extends AbstractController
);
}
#[Route('/api/storeroom/ticket/attachments/upload/{code}', name: 'app_storeroom_ticket_upload_attachment', methods: ['POST'])]
public function uploadTicketAttachment(string $code, Request $request, Access $access, EntityManagerInterface $entityManager, \App\Service\FileStorage $storage): JsonResponse
{
$acc = $access->hasRole('store');
if (!$acc) throw $this->createAccessDeniedException();
$ticket = $entityManager->getRepository(StoreroomTicket::class)->findOneBy(['bid'=>$acc['bid'],'code'=>$code]);
if (!$ticket) throw $this->createNotFoundException('حواله یافت نشد');
$file = $request->files->get('file');
if (!$file) {
return $this->json(['result'=>-1,'message'=>'فایل ارسال نشده است'], 400);
}
// Store securely in var/storage
$stored = $storage->store($file, (string)$acc['bid']->getId(), 'storeroom_attachments');
$archive = new ArchiveFile();
$archive->setBid($acc['bid']);
$archive->setSubmitter($acc['user']);
$archive->setDateSubmit(date('Y-m-d H:i:s'));
$archive->setFilename($stored['relativePath']);
$archive->setCat('storeroom_ticket');
$archive->setFileType($stored['mime'] ?: 'application/octet-stream');
$archive->setPublic(false);
$archive->setDes($request->request->get('des'));
$archive->setRelatedDocType('storeroom_ticket');
$archive->setRelatedDocCode($ticket->getCode());
$archive->setFileSize($stored['size'] !== null ? (string)$stored['size'] : null);
$entityManager->persist($archive);
$entityManager->flush();
return $this->json(['result'=>0]);
}
#[Route('/api/storeroom/ticket/attachments/{code}', name: 'app_storeroom_ticket_list_attachments', methods: ['GET'])]
public function listTicketAttachments(string $code, Access $access, EntityManagerInterface $entityManager): JsonResponse
{
$acc = $access->hasRole('store');
if (!$acc) throw $this->createAccessDeniedException();
$ticket = $entityManager->getRepository(StoreroomTicket::class)->findOneBy(['bid'=>$acc['bid'],'code'=>$code]);
if (!$ticket) throw $this->createNotFoundException('حواله یافت نشد');
$items = $entityManager->getRepository(ArchiveFile::class)->findBy([
'bid'=>$acc['bid'],
'relatedDocType'=>'storeroom_ticket',
'relatedDocCode'=>$ticket->getCode()
], ['id'=>'DESC']);
return $this->json(array_map(function(ArchiveFile $a){
return [
'id'=>$a->getId(),
'filename'=>$a->getFilename(),
'fileType'=>$a->getFileType(),
'fileSize'=>$a->getFileSize(),
'des'=>$a->getDes(),
'dateSubmit'=>$a->getDateSubmit(),
];
}, $items));
}
#[Route('/api/storeroom/ticket/attachments/download/{id}', name: 'app_storeroom_ticket_download_attachment', methods: ['GET'])]
public function downloadTicketAttachment(int $id, Access $access, EntityManagerInterface $entityManager, \App\Service\FileStorage $storage): Response
{
$acc = $access->hasRole('store');
if (!$acc) throw $this->createAccessDeniedException();
$a = $entityManager->getRepository(ArchiveFile::class)->find($id);
if (!$a || $a->getBid()->getId() !== $acc['bid']->getId()) {
throw $this->createNotFoundException('فایل یافت نشد');
}
$abs = $storage->absolutePath((string)$a->getFilename());
if (!is_file($abs) || !is_readable($abs)) {
throw $this->createNotFoundException('فایل موجود نیست');
}
$response = new \Symfony\Component\HttpFoundation\BinaryFileResponse($abs);
$response->setContentDisposition(
\Symfony\Component\HttpFoundation\ResponseHeaderBag::DISPOSITION_ATTACHMENT,
basename($abs)
);
$response->headers->set('Content-Type', $a->getFileType() ?: 'application/octet-stream');
return $response;
}
#[Route('/api/storeroom/mod/{code}', name: 'app_storeroom_mod')]
public function app_storeroom_mod(Provider $provider, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager, $code = 0): JsonResponse
{
@ -134,8 +215,12 @@ class StoreroomController extends AbstractController
foreach ($buys as $buy) {
$temp = $provider->Entity2Array($buy, 0);
$person = $this->getPerson($buy);
$temp['person'] = Explore::ExplorePerson($person);
$temp['person']['des'] = ' # ' . $person->getCode() . ' ' . $person->getNikename();
if ($person) {
$temp['person'] = Explore::ExplorePerson($person);
$temp['person']['des'] = ' # ' . $person->getCode() . ' ' . $person->getNikename();
} else {
$temp['person'] = null;
}
$temp['commodities'] = $this->getCommodities($buy, $provider);
//check storeroom exist
$this->calcStoreRemaining($temp, $buy, $entityManager);
@ -155,8 +240,12 @@ class StoreroomController extends AbstractController
foreach ($sells as $sell) {
$temp = $provider->Entity2Array($sell, 0);
$person = $this->getPerson($sell);
$temp['person'] = Explore::ExplorePerson($person);
$temp['person']['des'] = ' # ' . $person->getCode() . ' ' . $person->getNikename();
if ($person) {
$temp['person'] = Explore::ExplorePerson($person);
$temp['person']['des'] = ' # ' . $person->getCode() . ' ' . $person->getNikename();
} else {
$temp['person'] = null;
}
$temp['commodities'] = $this->getCommodities($sell, $provider);
//check storeroom exist
$this->calcStoreRemaining($temp, $sell, $entityManager);
@ -176,8 +265,12 @@ class StoreroomController extends AbstractController
foreach ($rfsells as $sell) {
$temp = $provider->Entity2Array($sell, 0);
$person = $this->getPerson($sell);
$temp['person'] = Explore::ExplorePerson($person);
$temp['person']['des'] = ' # ' . $person->getCode() . ' ' . $person->getNikename();
if ($person) {
$temp['person'] = Explore::ExplorePerson($person);
$temp['person']['des'] = ' # ' . $person->getCode() . ' ' . $person->getNikename();
} else {
$temp['person'] = null;
}
$temp['commodities'] = $this->getCommodities($sell, $provider);
//check storeroom exist
$this->calcStoreRemaining($temp, $sell, $entityManager);
@ -197,8 +290,12 @@ class StoreroomController extends AbstractController
foreach ($rfbuys as $buy) {
$temp = $provider->Entity2Array($buy, 0);
$person = $this->getPerson($buy);
$temp['person'] = Explore::ExplorePerson($person);
$temp['person']['des'] = ' # ' . $person->getCode() . ' ' . $person->getNikename();
if ($person) {
$temp['person'] = Explore::ExplorePerson($person);
$temp['person']['des'] = ' # ' . $person->getCode() . ' ' . $person->getNikename();
} else {
$temp['person'] = null;
}
$temp['commodities'] = $this->getCommodities($buy, $provider);
//check storeroom exist
$this->calcStoreRemaining($temp, $buy, $entityManager);
@ -281,8 +378,12 @@ class StoreroomController extends AbstractController
}
}
$res = $provider->Entity2Array($doc, 0);
$res['person'] = $provider->Entity2Array($person, 0);
$res['person']['des'] = ' # ' . $person->getCode() . ' ' . $person->getNikename();
if ($person) {
$res['person'] = $provider->Entity2Array($person, 0);
$res['person']['des'] = ' # ' . $person->getCode() . ' ' . $person->getNikename();
} else {
$res['person'] = null;
}
$res['commodities'] = $provider->ArrayEntity2Array($commodities, 1, ['doc', 'bid', 'year']);
//calculate rows data
$this->calcStoreRemaining($res, $doc, $entityManager);
@ -403,6 +504,10 @@ class StoreroomController extends AbstractController
$ticket->setTransfer($params['ticket']['transfer']);
$ticket->setYear($acc['year']);
$ticket->setCode($provider->getAccountingCode($acc['bid'], 'storeroom'));
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
$rand = '';
for ($i = 0; $i < 8; $i++) { $rand .= $alphabet[random_int(0, strlen($alphabet)-1)]; }
$ticket->setActivationCode($rand);
$ticket->setReceiver($params['ticket']['receiver']);
$ticket->setTransferType($transferType);
$ticket->setReferral($params['ticket']['referral']);
@ -411,6 +516,9 @@ class StoreroomController extends AbstractController
$ticket->setType($params['ticket']['type']);
$ticket->setTypeString($params['ticket']['typeString']);
$ticket->setDes($params['ticket']['des']);
if (array_key_exists('importWorkflowCode', $params['ticket'])) {
$ticket->setImportWorkflowCode($params['ticket']['importWorkflowCode']);
}
$entityManager->persist($ticket);
//$entityManager->flush();
@ -418,6 +526,26 @@ class StoreroomController extends AbstractController
$docRows = $entityManager->getRepository(HesabdariRow::class)->findBy([
'doc' => $doc
]);
// Determine if warranty serials are required based on flag or provided lines
$hasSerialLines = false;
foreach (($params['items'] ?? []) as $it) {
if (!empty($it['serialLines']) && is_array($it['serialLines'])) { $hasSerialLines = true; break; }
}
$requireWarrantySerial = (isset($params['ticket']['requireWarrantySerial']) && $params['ticket']['requireWarrantySerial'] === true) || $hasSerialLines;
if ($requireWarrantySerial) {
if (!$pluginService->isActive('warranty', $acc['bid'])) {
return $this->json(['result' => -5, 'message' => 'افزونه گارانتی فعال نیست'], 403);
}
// Validate counts up-front
foreach ($params['items'] as $item) {
$lines = isset($item['serialLines']) && is_array($item['serialLines']) ? $item['serialLines'] : [];
if ((int)($item['ticketCount'] ?? 0) > 0 && count($lines) < (int)$item['ticketCount']) {
return $this->json(['result' => -3, 'message' => 'تعداد سریال/گارانتی با تعداد حواله همخوانی ندارد'], 400);
}
}
}
foreach ($params['items'] as $item) {
$row = $entityManager->getRepository(HesabdariRow::class)->findOneBy([
'bid' => $acc['bid'],
@ -441,8 +569,58 @@ class StoreroomController extends AbstractController
$ticketItem->setCommodity($row->getCommodity());
$ticketItem->setType($item['type']);
$entityManager->persist($ticketItem);
// Bind warranty serials per item if provided
if ($requireWarrantySerial) {
$lines = isset($item['serialLines']) && is_array($item['serialLines']) ? $item['serialLines'] : [];
if ((int)$item['ticketCount'] > 0) {
// Ensure we have an id to bind to
$entityManager->flush();
$lines = array_slice($lines, 0, (int)$item['ticketCount']);
foreach ($lines as $ln) {
$warrantyCode = $ln['warranty'] ?? null;
$deviceSerial = $ln['serial'] ?? null;
if (!$warrantyCode) {
return $this->json(['result' => -4, 'message' => 'کد گارانتی ارسال نشده است'], 400);
}
/** @var PlugWarrantySerial|null $serial */
$serial = $entityManager->getRepository(PlugWarrantySerial::class)->findOneBy([
'business' => $acc['bid'],
'serialNumber' => $warrantyCode,
'commodity' => $row->getCommodity(),
]);
if (!$serial || $serial->getStatus() !== PlugWarrantySerial::STATUS_AVAILABLE) {
return $this->json(['result' => -4, 'message' => 'گارانتی نامعتبر یا آزاد نیست: ' . $warrantyCode], 400);
}
$serial->setStatus(PlugWarrantySerial::STATUS_CONSUMED);
$serial->setCommoditySerial($deviceSerial);
$serial->setBuyer($person);
$serial->setAllocatedToDocumentId($doc->getId());
$serial->setAllocatedAt(new \DateTimeImmutable());
$serial->setBoundToItemId($ticketItem->getId());
$serial->setBoundAt(new \DateTimeImmutable());
$serial->setActivationTicketCode($ticket->getCode());
$serial->setActivationTicketSecret($ticket->getActivationCode());
$entityManager->persist($serial);
}
}
}
}
$entityManager->flush();
$business = $entityManager->getRepository(\App\Entity\Business::class)->find($acc['bid']);
$businessRequire = $business && method_exists($business, 'isRequireTwoStepApproval') ? (bool)$business->isRequireTwoStepApproval() : false;
if ($businessRequire) {
$ticket->setIsPreview(true);
$ticket->setIsApproved(false);
$ticket->setApprovedBy(null); // هنوز تأیید نشده
} else {
$ticket->setIsPreview(false);
$ticket->setIsApproved(true);
$ticket->setApprovedBy($this->getUser()); // تأیید شده توسط کاربر فعلی
}
//save logs
$log->insert('انبارداری', 'حواله انبار با شماره ' . $ticket->getCode() . ' اضافه / ویرایش شد.', $this->getUser(), $acc['bid']);
if ($pluginService->isActive('accpro', $acc['bid'])) {
@ -503,6 +681,58 @@ class StoreroomController extends AbstractController
]);
}
#[Route('/api/storeroom/ticket/status/{code}', name: 'app_storeroom_ticket_status_update', methods: ['POST'])]
public function app_storeroom_ticket_status_update(string $code, Request $request, Access $access, EntityManagerInterface $entityManager): JsonResponse
{
$acc = $access->hasRole('store');
if (!$acc)
throw $this->createAccessDeniedException();
$params = json_decode($request->getContent() ?: '{}', true);
$status = $params['status'] ?? null; // in_progress|done|rejected|approved|pending_approval
if (!in_array($status, ['in_progress','done','rejected','approved','pending_approval'])) {
return $this->json(['result' => -1, 'message' => 'وضعیت نامعتبر'], 400);
}
$ticket = $entityManager->getRepository(StoreroomTicket::class)->findOneBy([
'bid' => $acc['bid'],
'code' => $code
]);
if (!$ticket) {
throw $this->createNotFoundException('حواله یافت نشد.');
}
// $ticket->setStatus($status);
$entityManager->persist($ticket);
$entityManager->flush();
return $this->json(['result' => 0]);
}
#[Route('/api/storeroom/tickets', name: 'app_storeroom_tickets_by_status', methods: ['GET'])]
public function app_storeroom_tickets_by_status(Request $request, Access $access, EntityManagerInterface $entityManager): JsonResponse
{
$acc = $access->hasRole('store');
if (!$acc)
throw $this->createAccessDeniedException();
$status = $request->query->get('status');
$criteria = [
'bid' => $acc['bid'],
'year' => $acc['year'],
];
if ($status) {
$criteria['status'] = $status;
}
$tickets = $entityManager->getRepository(StoreroomTicket::class)->findBy($criteria, ['date' => 'DESC']);
return $this->json(array_map(function(StoreroomTicket $t){
return [
'code' => $t->getCode(),
'date' => $t->getDate(),
'type' => $t->getType(),
'typeString' => $t->getTypeString(),
'importWorkflowCode' => $t->getImportWorkflowCode(),
'person' => $t->getPerson() ? $t->getPerson()->getNikename() : null,
'storeroom' => $t->getStoreroom() ? $t->getStoreroom()->getName() : null,
];
}, $tickets));
}
#[Route('/api/storeroom/tickets/list/{type}', name: 'app_storeroom_tickets_list')]
public function app_storeroom_tickets_list(string $type, Provider $provider, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager): JsonResponse
{
@ -516,15 +746,33 @@ class StoreroomController extends AbstractController
], [
'date' => 'DESC'
]);
return $this->json($provider->ArrayEntity2ArrayJustIncludes($tickets, [
$result = $provider->ArrayEntity2ArrayJustIncludes($tickets, [
'getDes',
'getCode',
'getDate',
'getPerson',
'getNikename',
'getDoc',
'getTypeString'
]));
'getTypeString',
'isPreview',
'isApproved'
], 2);
foreach ($result as $key => &$ticket) {
$ticketEntity = $tickets[$key];
if ($ticketEntity->getApprovedBy()) {
$approvedBy = $ticketEntity->getApprovedBy();
$ticket['approvedBy'] = [
'id' => $approvedBy->getId(),
'fullName' => $approvedBy->getFullName(),
'email' => $approvedBy->getEmail()
];
} else {
$ticket['approvedBy'] = null;
}
}
return $this->json($result);
}
#[Route('/api/storeroom/tickets/info/{code}', name: 'app_storeroom_ticket_view')]
@ -542,7 +790,7 @@ class StoreroomController extends AbstractController
//get items
$items = $entityManager->getRepository(StoreroomItem::class)->findBy(['ticket' => $ticket]);
$res = [];
$res['ticket'] = $provider->Entity2ArrayJustIncludes($ticket, ['getStoreroom', 'getManager', 'getDate', 'getSubmitDate', 'getDes', 'getReceiver', 'getTransfer', 'getCode', 'getType', 'getReferral', 'getTypeString'], 2);
$res['ticket'] = $provider->Entity2ArrayJustIncludes($ticket, ['getStoreroom', 'getManager', 'getDate', 'getSubmitDate', 'getDes', 'getReceiver', 'getTransfer', 'getCode', 'getType', 'getReferral', 'getTypeString', 'isPreview', 'isApproved'], 2);
$res['transferType'] = $provider->Entity2ArrayJustIncludes($ticket->getTransferType(), ['getName'], 0);
$res['person'] = $provider->Entity2ArrayJustIncludes($ticket->getPerson(), ['getKeshvar', 'getOstan', 'getShahr', 'getAddress', 'getNikename', 'getCodeeghtesadi', 'getPostalcode', 'getName', 'getTel', 'getSabt'], 0);
//get rows
@ -659,6 +907,15 @@ class StoreroomController extends AbstractController
} else {
$title = 'حواله خروج از انبار';
}
$business = $entityManager->getRepository(\App\Entity\Business::class)->find($acc['bid']);
$businessRequire = $business && method_exists($business, 'isRequireTwoStepApproval') ? (bool)$business->isRequireTwoStepApproval() : false;
if ($businessRequire) {
// بررسی وضعیت تأیید از طریق StoreroomTicket
if ($doc->isPreview()) {
return $this->json(['result' => -10, 'message' => 'حواله هنوز تایید نشده است'], 403);
}
}
$pdfPid = 0;
$pdfPid = $provider->createPrint(
$acc['bid'],

View file

@ -117,6 +117,9 @@ class Business
#[ORM\Column(length: 255, nullable: true)]
private ?string $cashdeskCode = '1000';
#[ORM\Column(type: Types::BIGINT, nullable: true)]
private ?string $importWorkflowCode = null;
#[ORM\OneToMany(mappedBy: 'bid', targetEntity: Salary::class, orphanRemoval: true)]
private Collection $salaries;
@ -307,6 +310,21 @@ class Business
#[Ignore]
private Collection $plugWarrantySerials;
#[ORM\OneToMany(mappedBy: 'business', targetEntity: ImportWorkflow::class, orphanRemoval: true)]
private Collection $importWorkflows;
#[ORM\Column(nullable: true)]
private ?bool $requireTwoStepApproval = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $invoiceApprover = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $warehouseApprover = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $financialApprover = null;
public function __construct()
{
$this->logs = new ArrayCollection();
@ -352,6 +370,7 @@ class Business
$this->plugHrmDocs = new ArrayCollection();
$this->aiConversations = new ArrayCollection();
$this->plugWarrantySerials = new ArrayCollection();
$this->importWorkflows = new ArrayCollection();
}
public function getId(): ?int
@ -879,6 +898,18 @@ class Business
return $this;
}
public function getImportWorkflowCode(): ?string
{
return $this->importWorkflowCode;
}
public function setImportWorkflowCode(?string $importWorkflowCode): self
{
$this->importWorkflowCode = $importWorkflowCode;
return $this;
}
/**
* @return Collection<int, Salary>
*/
@ -2155,4 +2186,72 @@ class Business
return $this;
}
public function getImportWorkflows(): Collection
{
return $this->importWorkflows;
}
public function addImportWorkflow(ImportWorkflow $importWorkflow): static
{
if (!$this->importWorkflows->contains($importWorkflow)) {
$this->importWorkflows->add($importWorkflow);
$importWorkflow->setBusiness($this);
}
return $this;
}
public function removeImportWorkflow(ImportWorkflow $importWorkflow): static
{
if ($this->importWorkflows->removeElement($importWorkflow)) {
if ($importWorkflow->getBusiness() === $this) {
$importWorkflow->setBusiness(null);
}
}
return $this;
}
public function isRequireTwoStepApproval(): ?bool
{
return $this->requireTwoStepApproval;
}
public function setRequireTwoStepApproval(?bool $requireTwoStepApproval): static
{
$this->requireTwoStepApproval = $requireTwoStepApproval;
return $this;
}
public function getInvoiceApprover(): ?string
{
return $this->invoiceApprover;
}
public function setInvoiceApprover(?string $invoiceApprover): static
{
$this->invoiceApprover = $invoiceApprover;
return $this;
}
public function getWarehouseApprover(): ?string
{
return $this->warehouseApprover;
}
public function setWarehouseApprover(?string $warehouseApprover): static
{
$this->warehouseApprover = $warehouseApprover;
return $this;
}
public function getFinancialApprover(): ?string
{
return $this->financialApprover;
}
public function setFinancialApprover(?string $financialApprover): static
{
$this->financialApprover = $financialApprover;
return $this;
}
}

View file

@ -726,4 +726,48 @@ class HesabdariDoc
return $this;
}
// Approval fields
#[ORM\Column(nullable: true)]
private ?bool $isPreview = null;
#[ORM\Column(nullable: true)]
private ?bool $isApproved = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
private ?User $approvedBy = null;
public function isPreview(): ?bool
{
return $this->isPreview;
}
public function setIsPreview(?bool $isPreview): static
{
$this->isPreview = $isPreview;
return $this;
}
public function isApproved(): ?bool
{
return $this->isApproved;
}
public function setIsApproved(?bool $isApproved): static
{
$this->isApproved = $isApproved;
return $this;
}
public function getApprovedBy(): ?User
{
return $this->approvedBy;
}
public function setApprovedBy(?User $approvedBy): static
{
$this->approvedBy = $approvedBy;
return $this;
}
}

View file

@ -368,4 +368,6 @@ class HesabdariRow
return $this;
}
}

View file

@ -0,0 +1,441 @@
<?php
namespace App\Entity;
use App\Repository\ImportWorkflowRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity(repositoryClass: ImportWorkflowRepository::class)]
class ImportWorkflow
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true)]
private ?string $code = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\ManyToOne(inversedBy: 'importWorkflows')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?Business $business = null;
#[ORM\ManyToOne(inversedBy: 'importWorkflows')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?User $submitter = null;
#[ORM\Column(length: 255)]
private ?string $dateSubmit = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $dateMod = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $status = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $supplierName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $supplierCountry = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $supplierAddress = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $supplierPhone = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $supplierEmail = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $totalAmount = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $currency = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $exchangeRate = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $totalAmountIRR = null;
#[ORM\OneToMany(mappedBy: 'importWorkflow', targetEntity: ImportWorkflowItem::class, orphanRemoval: true)]
private Collection $items;
#[ORM\OneToMany(mappedBy: 'importWorkflow', targetEntity: ImportWorkflowPayment::class, orphanRemoval: true)]
private Collection $payments;
#[ORM\OneToMany(mappedBy: 'importWorkflow', targetEntity: ImportWorkflowDocument::class, orphanRemoval: true)]
private Collection $documents;
#[ORM\OneToMany(mappedBy: 'importWorkflow', targetEntity: ImportWorkflowStage::class, orphanRemoval: true)]
private Collection $stages;
#[ORM\OneToMany(mappedBy: 'importWorkflow', targetEntity: ImportWorkflowShipping::class, orphanRemoval: true)]
private Collection $shipping;
#[ORM\OneToMany(mappedBy: 'importWorkflow', targetEntity: ImportWorkflowCustoms::class, orphanRemoval: true)]
private Collection $customs;
public function __construct()
{
$this->items = new ArrayCollection();
$this->payments = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->stages = new ArrayCollection();
$this->shipping = new ArrayCollection();
$this->customs = new ArrayCollection();
$this->dateSubmit = date('Y-m-d H:i:s');
$this->status = 'draft';
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getBusiness(): ?Business
{
return $this->business;
}
public function setBusiness(?Business $business): static
{
$this->business = $business;
return $this;
}
public function getSubmitter(): ?User
{
return $this->submitter;
}
public function setSubmitter(?User $submitter): static
{
$this->submitter = $submitter;
return $this;
}
public function getDateSubmit(): ?string
{
return $this->dateSubmit;
}
public function setDateSubmit(string $dateSubmit): static
{
$this->dateSubmit = $dateSubmit;
return $this;
}
public function getDateMod(): ?string
{
return $this->dateMod;
}
public function setDateMod(?string $dateMod): static
{
$this->dateMod = $dateMod;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(?string $status): static
{
$this->status = $status;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getSupplierName(): ?string
{
return $this->supplierName;
}
public function setSupplierName(?string $supplierName): static
{
$this->supplierName = $supplierName;
return $this;
}
public function getSupplierCountry(): ?string
{
return $this->supplierCountry;
}
public function setSupplierCountry(?string $supplierCountry): static
{
$this->supplierCountry = $supplierCountry;
return $this;
}
public function getSupplierAddress(): ?string
{
return $this->supplierAddress;
}
public function setSupplierAddress(?string $supplierAddress): static
{
$this->supplierAddress = $supplierAddress;
return $this;
}
public function getSupplierPhone(): ?string
{
return $this->supplierPhone;
}
public function setSupplierPhone(?string $supplierPhone): static
{
$this->supplierPhone = $supplierPhone;
return $this;
}
public function getSupplierEmail(): ?string
{
return $this->supplierEmail;
}
public function setSupplierEmail(?string $supplierEmail): static
{
$this->supplierEmail = $supplierEmail;
return $this;
}
public function getTotalAmount(): ?string
{
return $this->totalAmount;
}
public function setTotalAmount(?string $totalAmount): static
{
$this->totalAmount = $totalAmount;
return $this;
}
public function getCurrency(): ?string
{
return $this->currency;
}
public function setCurrency(?string $currency): static
{
$this->currency = $currency;
return $this;
}
public function getExchangeRate(): ?string
{
return $this->exchangeRate;
}
public function setExchangeRate(?string $exchangeRate): static
{
$this->exchangeRate = $exchangeRate;
return $this;
}
public function getTotalAmountIRR(): ?string
{
return $this->totalAmountIRR;
}
public function setTotalAmountIRR(?string $totalAmountIRR): static
{
$this->totalAmountIRR = $totalAmountIRR;
return $this;
}
public function getItems(): Collection
{
return $this->items;
}
public function addItem(ImportWorkflowItem $item): static
{
if (!$this->items->contains($item)) {
$this->items->add($item);
$item->setImportWorkflow($this);
}
return $this;
}
public function removeItem(ImportWorkflowItem $item): static
{
if ($this->items->removeElement($item)) {
if ($item->getImportWorkflow() === $this) {
$item->setImportWorkflow(null);
}
}
return $this;
}
public function getPayments(): Collection
{
return $this->payments;
}
public function addPayment(ImportWorkflowPayment $payment): static
{
if (!$this->payments->contains($payment)) {
$this->payments->add($payment);
$payment->setImportWorkflow($this);
}
return $this;
}
public function removePayment(ImportWorkflowPayment $payment): static
{
if ($this->payments->removeElement($payment)) {
if ($payment->getImportWorkflow() === $this) {
$payment->setImportWorkflow(null);
}
}
return $this;
}
public function getDocuments(): Collection
{
return $this->documents;
}
public function addDocument(ImportWorkflowDocument $document): static
{
if (!$this->documents->contains($document)) {
$this->documents->add($document);
$document->setImportWorkflow($this);
}
return $this;
}
public function removeDocument(ImportWorkflowDocument $document): static
{
if ($this->documents->removeElement($document)) {
if ($document->getImportWorkflow() === $this) {
$document->setImportWorkflow(null);
}
}
return $this;
}
public function getStages(): Collection
{
return $this->stages;
}
public function addStage(ImportWorkflowStage $stage): static
{
if (!$this->stages->contains($stage)) {
$this->stages->add($stage);
$stage->setImportWorkflow($this);
}
return $this;
}
public function removeStage(ImportWorkflowStage $stage): static
{
if ($this->stages->removeElement($stage)) {
if ($stage->getImportWorkflow() === $this) {
$stage->setImportWorkflow(null);
}
}
return $this;
}
public function getShipping(): Collection
{
return $this->shipping;
}
public function addShipping(ImportWorkflowShipping $shipping): static
{
if (!$this->shipping->contains($shipping)) {
$this->shipping->add($shipping);
$shipping->setImportWorkflow($this);
}
return $this;
}
public function removeShipping(ImportWorkflowShipping $shipping): static
{
if ($this->shipping->removeElement($shipping)) {
if ($shipping->getImportWorkflow() === $this) {
$shipping->setImportWorkflow(null);
}
}
return $this;
}
public function getCustoms(): Collection
{
return $this->customs;
}
public function addCustom(ImportWorkflowCustoms $custom): static
{
if (!$this->customs->contains($custom)) {
$this->customs->add($custom);
$custom->setImportWorkflow($this);
}
return $this;
}
public function removeCustom(ImportWorkflowCustoms $custom): static
{
if ($this->customs->removeElement($custom)) {
if ($custom->getImportWorkflow() === $this) {
$custom->setImportWorkflow(null);
}
}
return $this;
}
}

View file

@ -0,0 +1,254 @@
<?php
namespace App\Entity;
use App\Repository\ImportWorkflowCustomsRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity(repositoryClass: ImportWorkflowCustomsRepository::class)]
class ImportWorkflowCustoms
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'customs')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?ImportWorkflow $importWorkflow = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $declarationNumber = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $customsCode = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $clearanceDate = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $customsDuty = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $valueAddedTax = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $otherCharges = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $totalCustomsCharges = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $customsBroker = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $customsBrokerPhone = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $customsBrokerEmail = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $warehouseNumber = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $warehouseLocation = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 255)]
private ?string $dateSubmit = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $status = null;
public function __construct()
{
$this->dateSubmit = date('Y-m-d H:i:s');
$this->status = 'pending';
}
public function getId(): ?int
{
return $this->id;
}
public function getImportWorkflow(): ?ImportWorkflow
{
return $this->importWorkflow;
}
public function setImportWorkflow(?ImportWorkflow $importWorkflow): static
{
$this->importWorkflow = $importWorkflow;
return $this;
}
public function getDeclarationNumber(): ?string
{
return $this->declarationNumber;
}
public function setDeclarationNumber(?string $declarationNumber): static
{
$this->declarationNumber = $declarationNumber;
return $this;
}
public function getCustomsCode(): ?string
{
return $this->customsCode;
}
public function setCustomsCode(?string $customsCode): static
{
$this->customsCode = $customsCode;
return $this;
}
public function getClearanceDate(): ?string
{
return $this->clearanceDate;
}
public function setClearanceDate(?string $clearanceDate): static
{
$this->clearanceDate = $clearanceDate;
return $this;
}
public function getCustomsDuty(): ?string
{
return $this->customsDuty;
}
public function setCustomsDuty(?string $customsDuty): static
{
$this->customsDuty = $customsDuty;
return $this;
}
public function getValueAddedTax(): ?string
{
return $this->valueAddedTax;
}
public function setValueAddedTax(?string $valueAddedTax): static
{
$this->valueAddedTax = $valueAddedTax;
return $this;
}
public function getOtherCharges(): ?string
{
return $this->otherCharges;
}
public function setOtherCharges(?string $otherCharges): static
{
$this->otherCharges = $otherCharges;
return $this;
}
public function getTotalCustomsCharges(): ?string
{
return $this->totalCustomsCharges;
}
public function setTotalCustomsCharges(?string $totalCustomsCharges): static
{
$this->totalCustomsCharges = $totalCustomsCharges;
return $this;
}
public function getCustomsBroker(): ?string
{
return $this->customsBroker;
}
public function setCustomsBroker(?string $customsBroker): static
{
$this->customsBroker = $customsBroker;
return $this;
}
public function getCustomsBrokerPhone(): ?string
{
return $this->customsBrokerPhone;
}
public function setCustomsBrokerPhone(?string $customsBrokerPhone): static
{
$this->customsBrokerPhone = $customsBrokerPhone;
return $this;
}
public function getCustomsBrokerEmail(): ?string
{
return $this->customsBrokerEmail;
}
public function setCustomsBrokerEmail(?string $customsBrokerEmail): static
{
$this->customsBrokerEmail = $customsBrokerEmail;
return $this;
}
public function getWarehouseNumber(): ?string
{
return $this->warehouseNumber;
}
public function setWarehouseNumber(?string $warehouseNumber): static
{
$this->warehouseNumber = $warehouseNumber;
return $this;
}
public function getWarehouseLocation(): ?string
{
return $this->warehouseLocation;
}
public function setWarehouseLocation(?string $warehouseLocation): static
{
$this->warehouseLocation = $warehouseLocation;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getDateSubmit(): ?string
{
return $this->dateSubmit;
}
public function setDateSubmit(string $dateSubmit): static
{
$this->dateSubmit = $dateSubmit;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(?string $status): static
{
$this->status = $status;
return $this;
}
}

View file

@ -0,0 +1,213 @@
<?php
namespace App\Entity;
use App\Repository\ImportWorkflowDocumentRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity(repositoryClass: ImportWorkflowDocumentRepository::class)]
class ImportWorkflowDocument
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?ImportWorkflow $importWorkflow = null;
#[ORM\Column(length: 255)]
private ?string $type = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $filePath = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $fileName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $fileSize = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $fileType = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 255)]
private ?string $dateSubmit = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $documentNumber = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $issueDate = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $expiryDate = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $status = null;
public function __construct()
{
$this->dateSubmit = date('Y-m-d H:i:s');
$this->status = 'active';
}
public function getId(): ?int
{
return $this->id;
}
public function getImportWorkflow(): ?ImportWorkflow
{
return $this->importWorkflow;
}
public function setImportWorkflow(?ImportWorkflow $importWorkflow): static
{
$this->importWorkflow = $importWorkflow;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getFilePath(): ?string
{
return $this->filePath;
}
public function setFilePath(?string $filePath): static
{
$this->filePath = $filePath;
return $this;
}
public function getFileName(): ?string
{
return $this->fileName;
}
public function setFileName(?string $fileName): static
{
$this->fileName = $fileName;
return $this;
}
public function getFileSize(): ?string
{
return $this->fileSize;
}
public function setFileSize(?string $fileSize): static
{
$this->fileSize = $fileSize;
return $this;
}
public function getFileType(): ?string
{
return $this->fileType;
}
public function setFileType(?string $fileType): static
{
$this->fileType = $fileType;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getDateSubmit(): ?string
{
return $this->dateSubmit;
}
public function setDateSubmit(string $dateSubmit): static
{
$this->dateSubmit = $dateSubmit;
return $this;
}
public function getDocumentNumber(): ?string
{
return $this->documentNumber;
}
public function setDocumentNumber(?string $documentNumber): static
{
$this->documentNumber = $documentNumber;
return $this;
}
public function getIssueDate(): ?string
{
return $this->issueDate;
}
public function setIssueDate(?string $issueDate): static
{
$this->issueDate = $issueDate;
return $this;
}
public function getExpiryDate(): ?string
{
return $this->expiryDate;
}
public function setExpiryDate(?string $expiryDate): static
{
$this->expiryDate = $expiryDate;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(?string $status): static
{
$this->status = $status;
return $this;
}
}

View file

@ -0,0 +1,270 @@
<?php
namespace App\Entity;
use App\Repository\ImportWorkflowItemRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
use App\Entity\Commodity;
#[ORM\Entity(repositoryClass: ImportWorkflowItemRepository::class)]
class ImportWorkflowItem
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'items')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?ImportWorkflow $importWorkflow = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
private ?Commodity $commodity = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $productCode = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $brand = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $model = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $originCountry = null;
#[ORM\Column(length: 255)]
private ?string $quantity = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $unitPrice = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $unitPriceIRR = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $totalPrice = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $totalPriceIRR = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $weight = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $volume = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $specifications = null;
#[ORM\Column(length: 255)]
private ?string $dateSubmit = null;
public function __construct()
{
$this->dateSubmit = date('Y-m-d H:i:s');
}
public function getId(): ?int
{
return $this->id;
}
public function getImportWorkflow(): ?ImportWorkflow
{
return $this->importWorkflow;
}
public function setImportWorkflow(?ImportWorkflow $importWorkflow): static
{
$this->importWorkflow = $importWorkflow;
return $this;
}
public function getCommodity(): ?Commodity
{
return $this->commodity;
}
public function setCommodity(?Commodity $commodity): static
{
$this->commodity = $commodity;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getProductCode(): ?string
{
return $this->productCode;
}
public function setProductCode(?string $productCode): static
{
$this->productCode = $productCode;
return $this;
}
public function getBrand(): ?string
{
return $this->brand;
}
public function setBrand(?string $brand): static
{
$this->brand = $brand;
return $this;
}
public function getModel(): ?string
{
return $this->model;
}
public function setModel(?string $model): static
{
$this->model = $model;
return $this;
}
public function getOriginCountry(): ?string
{
return $this->originCountry;
}
public function setOriginCountry(?string $originCountry): static
{
$this->originCountry = $originCountry;
return $this;
}
public function getQuantity(): ?string
{
return $this->quantity;
}
public function setQuantity(string $quantity): static
{
$this->quantity = $quantity;
return $this;
}
public function getUnitPrice(): ?string
{
return $this->unitPrice;
}
public function setUnitPrice(?string $unitPrice): static
{
$this->unitPrice = $unitPrice;
return $this;
}
public function getUnitPriceIRR(): ?string
{
return $this->unitPriceIRR;
}
public function setUnitPriceIRR(?string $unitPriceIRR): static
{
$this->unitPriceIRR = $unitPriceIRR;
return $this;
}
public function getTotalPrice(): ?string
{
return $this->totalPrice;
}
public function setTotalPrice(?string $totalPrice): static
{
$this->totalPrice = $totalPrice;
return $this;
}
public function getTotalPriceIRR(): ?string
{
return $this->totalPriceIRR;
}
public function setTotalPriceIRR(?string $totalPriceIRR): static
{
$this->totalPriceIRR = $totalPriceIRR;
return $this;
}
public function getWeight(): ?string
{
return $this->weight;
}
public function setWeight(?string $weight): static
{
$this->weight = $weight;
return $this;
}
public function getVolume(): ?string
{
return $this->volume;
}
public function setVolume(?string $volume): static
{
$this->volume = $volume;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getSpecifications(): ?string
{
return $this->specifications;
}
public function setSpecifications(?string $specifications): static
{
$this->specifications = $specifications;
return $this;
}
public function getDateSubmit(): ?string
{
return $this->dateSubmit;
}
public function setDateSubmit(string $dateSubmit): static
{
$this->dateSubmit = $dateSubmit;
return $this;
}
}

View file

@ -0,0 +1,227 @@
<?php
namespace App\Entity;
use App\Repository\ImportWorkflowPaymentRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity(repositoryClass: ImportWorkflowPaymentRepository::class)]
class ImportWorkflowPayment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'payments')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?ImportWorkflow $importWorkflow = null;
#[ORM\Column(length: 255)]
private ?string $type = null;
#[ORM\Column(length: 255)]
private ?string $amount = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $currency = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $amountIRR = null;
#[ORM\Column(length: 255)]
private ?string $paymentDate = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $referenceNumber = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $bankName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $accountNumber = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $recipientName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $status = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $receiptNumber = null;
#[ORM\Column(length: 255)]
private ?string $dateSubmit = null;
public function __construct()
{
$this->dateSubmit = date('Y-m-d H:i:s');
$this->status = 'pending';
}
public function getId(): ?int
{
return $this->id;
}
public function getImportWorkflow(): ?ImportWorkflow
{
return $this->importWorkflow;
}
public function setImportWorkflow(?ImportWorkflow $importWorkflow): static
{
$this->importWorkflow = $importWorkflow;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getAmount(): ?string
{
return $this->amount;
}
public function setAmount(string $amount): static
{
$this->amount = $amount;
return $this;
}
public function getCurrency(): ?string
{
return $this->currency;
}
public function setCurrency(?string $currency): static
{
$this->currency = $currency;
return $this;
}
public function getAmountIRR(): ?string
{
return $this->amountIRR;
}
public function setAmountIRR(?string $amountIRR): static
{
$this->amountIRR = $amountIRR;
return $this;
}
public function getPaymentDate(): ?string
{
return $this->paymentDate;
}
public function setPaymentDate(string $paymentDate): static
{
$this->paymentDate = $paymentDate;
return $this;
}
public function getReferenceNumber(): ?string
{
return $this->referenceNumber;
}
public function setReferenceNumber(?string $referenceNumber): static
{
$this->referenceNumber = $referenceNumber;
return $this;
}
public function getBankName(): ?string
{
return $this->bankName;
}
public function setBankName(?string $bankName): static
{
$this->bankName = $bankName;
return $this;
}
public function getAccountNumber(): ?string
{
return $this->accountNumber;
}
public function setAccountNumber(?string $accountNumber): static
{
$this->accountNumber = $accountNumber;
return $this;
}
public function getRecipientName(): ?string
{
return $this->recipientName;
}
public function setRecipientName(?string $recipientName): static
{
$this->recipientName = $recipientName;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(?string $status): static
{
$this->status = $status;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getReceiptNumber(): ?string
{
return $this->receiptNumber;
}
public function setReceiptNumber(?string $receiptNumber): static
{
$this->receiptNumber = $receiptNumber;
return $this;
}
public function getDateSubmit(): ?string
{
return $this->dateSubmit;
}
public function setDateSubmit(string $dateSubmit): static
{
$this->dateSubmit = $dateSubmit;
return $this;
}
}

View file

@ -0,0 +1,269 @@
<?php
namespace App\Entity;
use App\Repository\ImportWorkflowShippingRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity(repositoryClass: ImportWorkflowShippingRepository::class)]
class ImportWorkflowShipping
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'shipping')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?ImportWorkflow $importWorkflow = null;
#[ORM\Column(length: 255)]
private ?string $type = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $containerNumber = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $billOfLading = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $shippingDate = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $arrivalDate = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $unloadingDate = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $shippingCompany = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $shippingCompanyPhone = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $shippingCompanyEmail = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $originPort = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $destinationPort = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $vesselName = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $voyageNumber = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 255)]
private ?string $dateSubmit = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $status = null;
public function __construct()
{
$this->dateSubmit = date('Y-m-d H:i:s');
$this->status = 'pending';
}
public function getId(): ?int
{
return $this->id;
}
public function getImportWorkflow(): ?ImportWorkflow
{
return $this->importWorkflow;
}
public function setImportWorkflow(?ImportWorkflow $importWorkflow): static
{
$this->importWorkflow = $importWorkflow;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getContainerNumber(): ?string
{
return $this->containerNumber;
}
public function setContainerNumber(?string $containerNumber): static
{
$this->containerNumber = $containerNumber;
return $this;
}
public function getBillOfLading(): ?string
{
return $this->billOfLading;
}
public function setBillOfLading(?string $billOfLading): static
{
$this->billOfLading = $billOfLading;
return $this;
}
public function getShippingDate(): ?string
{
return $this->shippingDate;
}
public function setShippingDate(?string $shippingDate): static
{
$this->shippingDate = $shippingDate;
return $this;
}
public function getArrivalDate(): ?string
{
return $this->arrivalDate;
}
public function setArrivalDate(?string $arrivalDate): static
{
$this->arrivalDate = $arrivalDate;
return $this;
}
public function getUnloadingDate(): ?string
{
return $this->unloadingDate;
}
public function setUnloadingDate(?string $unloadingDate): static
{
$this->unloadingDate = $unloadingDate;
return $this;
}
public function getShippingCompany(): ?string
{
return $this->shippingCompany;
}
public function setShippingCompany(?string $shippingCompany): static
{
$this->shippingCompany = $shippingCompany;
return $this;
}
public function getShippingCompanyPhone(): ?string
{
return $this->shippingCompanyPhone;
}
public function setShippingCompanyPhone(?string $shippingCompanyPhone): static
{
$this->shippingCompanyPhone = $shippingCompanyPhone;
return $this;
}
public function getShippingCompanyEmail(): ?string
{
return $this->shippingCompanyEmail;
}
public function setShippingCompanyEmail(?string $shippingCompanyEmail): static
{
$this->shippingCompanyEmail = $shippingCompanyEmail;
return $this;
}
public function getOriginPort(): ?string
{
return $this->originPort;
}
public function setOriginPort(?string $originPort): static
{
$this->originPort = $originPort;
return $this;
}
public function getDestinationPort(): ?string
{
return $this->destinationPort;
}
public function setDestinationPort(?string $destinationPort): static
{
$this->destinationPort = $destinationPort;
return $this;
}
public function getVesselName(): ?string
{
return $this->vesselName;
}
public function setVesselName(?string $vesselName): static
{
$this->vesselName = $vesselName;
return $this;
}
public function getVoyageNumber(): ?string
{
return $this->voyageNumber;
}
public function setVoyageNumber(?string $voyageNumber): static
{
$this->voyageNumber = $voyageNumber;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getDateSubmit(): ?string
{
return $this->dateSubmit;
}
public function setDateSubmit(string $dateSubmit): static
{
$this->dateSubmit = $dateSubmit;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(?string $status): static
{
$this->status = $status;
return $this;
}
}

View file

@ -0,0 +1,157 @@
<?php
namespace App\Entity;
use App\Repository\ImportWorkflowStageRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity(repositoryClass: ImportWorkflowStageRepository::class)]
class ImportWorkflowStage
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'stages')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?ImportWorkflow $importWorkflow = null;
#[ORM\Column(length: 255)]
private ?string $stage = null;
#[ORM\Column(length: 255)]
private ?string $status = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $startDate = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $endDate = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $assignedTo = null;
#[ORM\Column(length: 255)]
private ?string $dateSubmit = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $notes = null;
public function __construct()
{
$this->dateSubmit = date('Y-m-d H:i:s');
$this->status = 'pending';
}
public function getId(): ?int
{
return $this->id;
}
public function getImportWorkflow(): ?ImportWorkflow
{
return $this->importWorkflow;
}
public function setImportWorkflow(?ImportWorkflow $importWorkflow): static
{
$this->importWorkflow = $importWorkflow;
return $this;
}
public function getStage(): ?string
{
return $this->stage;
}
public function setStage(string $stage): static
{
$this->stage = $stage;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getStartDate(): ?string
{
return $this->startDate;
}
public function setStartDate(?string $startDate): static
{
$this->startDate = $startDate;
return $this;
}
public function getEndDate(): ?string
{
return $this->endDate;
}
public function setEndDate(?string $endDate): static
{
$this->endDate = $endDate;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getAssignedTo(): ?string
{
return $this->assignedTo;
}
public function setAssignedTo(?string $assignedTo): static
{
$this->assignedTo = $assignedTo;
return $this;
}
public function getDateSubmit(): ?string
{
return $this->dateSubmit;
}
public function setDateSubmit(string $dateSubmit): static
{
$this->dateSubmit = $dateSubmit;
return $this;
}
public function getNotes(): ?string
{
return $this->notes;
}
public function setNotes(?string $notes): static
{
$this->notes = $notes;
return $this;
}
}

View file

@ -141,6 +141,12 @@ class Permission
#[ORM\Column(nullable: true)]
private ?bool $ai = null;
#[ORM\Column(nullable: true)]
private ?bool $warehouseManager = null;
#[ORM\Column(nullable: true)]
private ?bool $importWorkflow = null;
public function getId(): ?int
{
return $this->id;
@ -649,4 +655,28 @@ class Permission
return $this;
}
public function isWarehouseManager(): ?bool
{
return $this->warehouseManager;
}
public function setWarehouseManager(?bool $warehouseManager): static
{
$this->warehouseManager = $warehouseManager;
return $this;
}
public function isImportWorkflow(): ?bool
{
return $this->importWorkflow;
}
public function setImportWorkflow(?bool $importWorkflow): static
{
$this->importWorkflow = $importWorkflow;
return $this;
}
}

View file

@ -161,6 +161,8 @@ class Person
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $tags = null;
public function __construct()
{
$this->hesabdariRows = new ArrayCollection();
@ -913,4 +915,6 @@ class Person
$this->tags = $tags;
return $this;
}
}

View file

@ -2,170 +2,177 @@
namespace App\Entity;
use App\Repository\PlugWarrantySerialRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity(repositoryClass: PlugWarrantySerialRepository::class)]
#[ORM\Entity]
#[ORM\Table(name: 'plug_warranty_serial')]
#[ORM\UniqueConstraint(name: 'uniq_warranty_serial', columns: ['serial_number'])]
#[ORM\Index(name: 'idx_status_product', columns: ['status', 'commodity_id'])]
#[ORM\Index(name: 'idx_alloc_doc', columns: ['allocated_to_document_id'])]
class PlugWarrantySerial
{
public const STATUS_AVAILABLE = 'available';
public const STATUS_ALLOCATED = 'allocated';
public const STATUS_VERIFIED = 'verified';
public const STATUS_BOUND = 'bound';
public const STATUS_CONSUMED = 'consumed';
public const STATUS_VOID = 'void';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'plugWarrantySerials')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?Business $bid = null;
private ?Business $business = null;
#[ORM\ManyToOne(inversedBy: 'plugWarrantySerials')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?Commodity $commodity = null;
#[ORM\Column(length: 255, unique: true)]
#[ORM\Column(name: 'serial_number', length: 255, unique: true)]
private ?string $serialNumber = null;
#[ORM\Column(length: 25)]
private ?string $dateSubmit = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $dateSubmit;
#[ORM\ManyToOne(inversedBy: 'plugWarrantySerials')]
#[Ignore]
#[ORM\ManyToOne]
private ?User $submitter = null;
#[ORM\Column(length: 255, nullable: true)]
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 25, nullable: true)]
private ?string $warrantyStartDate = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $warrantyStartDate = null;
#[ORM\Column(length: 25, nullable: true)]
private ?string $warrantyEndDate = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $warrantyEndDate = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $status = 'active';
#[ORM\Column(length: 20)]
private string $status = self::STATUS_AVAILABLE;
#[ORM\Column(length: 255, nullable: true)]
#[ORM\Column(type: 'string', length: 20)]
private string $activation = 'deactive';
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $activationAt = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $notes = null;
#[ORM\Column(name: 'allocated_to_document_id', type: 'integer', nullable: true)]
private ?int $allocatedToDocumentId = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $allocatedAt = null;
#[ORM\Column(name: 'bound_to_item_id', type: 'integer', nullable: true)]
private ?int $boundToItemId = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $boundAt = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $voidReason = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $commoditySerial = null;
#[ORM\ManyToOne]
private ?Person $buyer = null;
#[ORM\Column(type: 'string', length: 32, nullable: true)]
private ?string $activationTicketCode = null;
#[ORM\Column(type: 'string', length: 32, nullable: true)]
private ?string $activationTicketSecret = null;
public function __construct()
{
$this->dateSubmit = date('Y-m-d H:i:s');
$this->dateSubmit = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
public function getId(): ?int { return $this->id; }
public function getBusiness(): ?Business { return $this->business; }
public function setBusiness(?Business $business): self { $this->business = $business; return $this; }
public function getCommodity(): ?Commodity { return $this->commodity; }
public function setCommodity(?Commodity $commodity): self { $this->commodity = $commodity; return $this; }
public function getSerialNumber(): ?string { return $this->serialNumber; }
public function setSerialNumber(string $serialNumber): self { $this->serialNumber = $serialNumber; return $this; }
public function getDateSubmit(): \DateTimeImmutable { return $this->dateSubmit; }
public function setDateSubmit(\DateTimeImmutable $d): self { $this->dateSubmit = $d; return $this; }
public function getSubmitter(): ?User { return $this->submitter; }
public function setSubmitter(?User $submitter): self { $this->submitter = $submitter; return $this; }
public function getDescription(): ?string { return $this->description; }
public function setDescription(?string $description): self { $this->description = $description; return $this; }
public function getWarrantyStartDate(): ?\DateTimeImmutable { return $this->warrantyStartDate; }
public function setWarrantyStartDate(?\DateTimeImmutable $d): self { $this->warrantyStartDate = $d; return $this; }
public function getWarrantyEndDate(): ?\DateTimeImmutable { return $this->warrantyEndDate; }
public function setWarrantyEndDate(?\DateTimeImmutable $d): self { $this->warrantyEndDate = $d; return $this; }
public function getStatus(): string { return $this->status; }
public function setStatus(string $status): self { $this->status = $status; return $this; }
public function getActivation(): string { return $this->activation; }
public function setActivation(string $activation): self { $this->activation = $activation; return $this; }
public function getActivationAt(): ?\DateTimeImmutable { return $this->activationAt; }
public function setActivationAt(?\DateTimeImmutable $d): self { $this->activationAt = $d; return $this; }
public function getNotes(): ?string { return $this->notes; }
public function setNotes(?string $notes): self { $this->notes = $notes; return $this; }
public function getAllocatedToDocumentId(): ?int { return $this->allocatedToDocumentId; }
public function setAllocatedToDocumentId(?int $id): self { $this->allocatedToDocumentId = $id; return $this; }
public function getAllocatedAt(): ?\DateTimeImmutable { return $this->allocatedAt; }
public function setAllocatedAt(?\DateTimeImmutable $d): self { $this->allocatedAt = $d; return $this; }
public function getBoundToItemId(): ?int { return $this->boundToItemId; }
public function setBoundToItemId(?int $id): self { $this->boundToItemId = $id; return $this; }
public function getBoundAt(): ?\DateTimeImmutable { return $this->boundAt; }
public function setBoundAt(?\DateTimeImmutable $d): self { $this->boundAt = $d; return $this; }
public function getVoidReason(): ?string { return $this->voidReason; }
public function setVoidReason(?string $r): self { $this->voidReason = $r; return $this; }
public function getCommoditySerial(): ?string { return $this->commoditySerial; }
public function setCommoditySerial(?string $commoditySerial): self { $this->commoditySerial = $commoditySerial; return $this; }
public function getBuyer(): ?Person { return $this->buyer; }
public function setBuyer(?Person $buyer): self { $this->buyer = $buyer; return $this; }
public function getActivationTicketCode(): ?string { return $this->activationTicketCode; }
public function setActivationTicketCode(?string $code): self { $this->activationTicketCode = $code; return $this; }
public function getActivationTicketSecret(): ?string { return $this->activationTicketSecret; }
public function setActivationTicketSecret(?string $secret): self { $this->activationTicketSecret = $secret; return $this; }
public function isUsed(): bool {
return $this->status === self::STATUS_CONSUMED;
}
public function getBid(): ?Business
{
return $this->bid;
}
public function setBid(?Business $bid): static
{
$this->bid = $bid;
public function setUsed(bool $used): self {
$this->status = $used ? self::STATUS_CONSUMED : self::STATUS_AVAILABLE;
return $this;
}
public function getCommodity(): ?Commodity
{
return $this->commodity;
}
public function setCommodity(?Commodity $commodity): static
{
$this->commodity = $commodity;
public function setUsedAt(string $usedAt): self {
$this->usedAt = new \DateTimeImmutable($usedAt);
return $this;
}
public function getSerialNumber(): ?string
{
return $this->serialNumber;
}
public function setSerialNumber(string $serialNumber): static
{
$this->serialNumber = $serialNumber;
return $this;
}
public function getDateSubmit(): ?string
{
return $this->dateSubmit;
}
public function setDateSubmit(string $dateSubmit): static
{
$this->dateSubmit = $dateSubmit;
return $this;
}
public function getSubmitter(): ?User
{
return $this->submitter;
}
public function setSubmitter(?User $submitter): static
{
$this->submitter = $submitter;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getWarrantyStartDate(): ?string
{
return $this->warrantyStartDate;
}
public function setWarrantyStartDate(?string $warrantyStartDate): static
{
$this->warrantyStartDate = $warrantyStartDate;
return $this;
}
public function getWarrantyEndDate(): ?string
{
return $this->warrantyEndDate;
}
public function setWarrantyEndDate(?string $warrantyEndDate): static
{
$this->warrantyEndDate = $warrantyEndDate;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(?string $status): static
{
$this->status = $status;
return $this;
}
public function getNotes(): ?string
{
return $this->notes;
}
public function setNotes(?string $notes): static
{
$this->notes = $notes;
public function setUsedTicketCode(string $ticketCode): self {
$this->usedTicketCode = $ticketCode;
return $this;
}
}

View file

@ -77,6 +77,25 @@ class StoreroomTicket
#[ORM\Column(nullable: true)]
private ?bool $canShare = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $importWorkflowCode = null;
#[ORM\Column(length: 32, nullable: true)]
private ?string $activationCode = null;
// Approval fields
#[ORM\Column(nullable: true)]
private ?bool $isPreview = null;
#[ORM\Column(nullable: true)]
private ?bool $isApproved = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
private ?User $approvedBy = null;
public function __construct()
{
$this->storeroomItems = new ArrayCollection();
@ -332,4 +351,62 @@ class StoreroomTicket
return $this;
}
public function getImportWorkflowCode(): ?string
{
return $this->importWorkflowCode;
}
public function setImportWorkflowCode(?string $importWorkflowCode): static
{
$this->importWorkflowCode = $importWorkflowCode;
return $this;
}
public function getActivationCode(): ?string
{
return $this->activationCode;
}
public function setActivationCode(?string $activationCode): static
{
$this->activationCode = $activationCode;
return $this;
}
// Approval methods
public function isPreview(): ?bool
{
return $this->isPreview;
}
public function setIsPreview(?bool $isPreview): static
{
$this->isPreview = $isPreview;
return $this;
}
public function isApproved(): ?bool
{
return $this->isApproved;
}
public function setIsApproved(?bool $isApproved): static
{
$this->isApproved = $isApproved;
return $this;
}
public function getApprovedBy(): ?User
{
return $this->approvedBy;
}
public function setApprovedBy(?User $approvedBy): static
{
$this->approvedBy = $approvedBy;
return $this;
}
}

View file

@ -144,6 +144,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\OneToMany(mappedBy: 'user', targetEntity: AIConversation::class, orphanRemoval: true)]
private Collection $aiConversations;
#[ORM\OneToMany(mappedBy: 'submitter', targetEntity: ImportWorkflow::class, orphanRemoval: true)]
private Collection $importWorkflows;
public function __construct()
{
$this->userTokens = new ArrayCollection();
@ -171,6 +174,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->backBuiltModules = new ArrayCollection();
$this->PlugGhestaDocs = new ArrayCollection();
$this->aiConversations = new ArrayCollection();
$this->importWorkflows = new ArrayCollection();
}
public function getId(): ?int
@ -1066,4 +1070,28 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getImportWorkflows(): Collection
{
return $this->importWorkflows;
}
public function addImportWorkflow(ImportWorkflow $importWorkflow): static
{
if (!$this->importWorkflows->contains($importWorkflow)) {
$this->importWorkflows->add($importWorkflow);
$importWorkflow->setSubmitter($this);
}
return $this;
}
public function removeImportWorkflow(ImportWorkflow $importWorkflow): static
{
if ($this->importWorkflows->removeElement($importWorkflow)) {
if ($importWorkflow->getSubmitter() === $this) {
$importWorkflow->setSubmitter(null);
}
}
return $this;
}
}

View file

@ -142,8 +142,11 @@ class CommodityRepository extends ServiceEntityRepository
}
}
$query->setMaxResults($params['Take'])
->orderBy('p.id', 'ASC');
if (isset($params['Take']) && $params['Take'] !== -1) {
$query->setMaxResults($params['Take']);
}
$query->orderBy('p.id', 'ASC');
return $query->getQuery()->getResult();
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Repository;
use App\Entity\ImportWorkflowCustoms;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ImportWorkflowCustomsRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ImportWorkflowCustoms::class);
}
public function findByImportWorkflow($importWorkflowId)
{
return $this->createQueryBuilder('iwc')
->andWhere('iwc.importWorkflow = :importWorkflowId')
->setParameter('importWorkflowId', $importWorkflowId)
->orderBy('iwc.dateSubmit', 'ASC')
->getQuery()
->getResult();
}
public function findByDeclarationNumber($declarationNumber, $importWorkflowId)
{
return $this->createQueryBuilder('iwc')
->andWhere('iwc.declarationNumber = :declarationNumber')
->andWhere('iwc.importWorkflow = :importWorkflowId')
->setParameter('declarationNumber', $declarationNumber)
->setParameter('importWorkflowId', $importWorkflowId)
->getQuery()
->getOneOrNullResult();
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Repository;
use App\Entity\ImportWorkflowDocument;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ImportWorkflowDocumentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ImportWorkflowDocument::class);
}
public function findByImportWorkflow($importWorkflowId)
{
return $this->createQueryBuilder('iwd')
->andWhere('iwd.importWorkflow = :importWorkflowId')
->setParameter('importWorkflowId', $importWorkflowId)
->orderBy('iwd.dateSubmit', 'ASC')
->getQuery()
->getResult();
}
public function findByType($type, $importWorkflowId)
{
return $this->createQueryBuilder('iwd')
->andWhere('iwd.type = :type')
->andWhere('iwd.importWorkflow = :importWorkflowId')
->setParameter('type', $type)
->setParameter('importWorkflowId', $importWorkflowId)
->orderBy('iwd.dateSubmit', 'ASC')
->getQuery()
->getResult();
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Repository;
use App\Entity\ImportWorkflowItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ImportWorkflowItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ImportWorkflowItem::class);
}
public function findByImportWorkflow($importWorkflowId)
{
return $this->createQueryBuilder('iwi')
->andWhere('iwi.importWorkflow = :importWorkflowId')
->setParameter('importWorkflowId', $importWorkflowId)
->orderBy('iwi.dateSubmit', 'ASC')
->getQuery()
->getResult();
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Repository;
use App\Entity\ImportWorkflowPayment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ImportWorkflowPaymentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ImportWorkflowPayment::class);
}
public function findByImportWorkflow($importWorkflowId)
{
return $this->createQueryBuilder('iwp')
->andWhere('iwp.importWorkflow = :importWorkflowId')
->setParameter('importWorkflowId', $importWorkflowId)
->orderBy('iwp.paymentDate', 'ASC')
->getQuery()
->getResult();
}
public function findByType($type, $importWorkflowId)
{
return $this->createQueryBuilder('iwp')
->andWhere('iwp.type = :type')
->andWhere('iwp.importWorkflow = :importWorkflowId')
->setParameter('type', $type)
->setParameter('importWorkflowId', $importWorkflowId)
->orderBy('iwp.paymentDate', 'ASC')
->getQuery()
->getResult();
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Repository;
use App\Entity\ImportWorkflow;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ImportWorkflowRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ImportWorkflow::class);
}
public function findByBusiness($businessId)
{
return $this->createQueryBuilder('iw')
->andWhere('iw.business = :businessId')
->setParameter('businessId', $businessId)
->orderBy('iw.dateSubmit', 'DESC')
->getQuery()
->getResult();
}
public function findByStatus($status, $businessId)
{
return $this->createQueryBuilder('iw')
->andWhere('iw.status = :status')
->andWhere('iw.business = :businessId')
->setParameter('status', $status)
->setParameter('businessId', $businessId)
->orderBy('iw.dateSubmit', 'DESC')
->getQuery()
->getResult();
}
public function findByCode($code, $businessId)
{
return $this->createQueryBuilder('iw')
->andWhere('iw.code = :code')
->andWhere('iw.business = :businessId')
->setParameter('code', $code)
->setParameter('businessId', $businessId)
->getQuery()
->getOneOrNullResult();
}
public function findWithDetails($id, $businessId)
{
return $this->createQueryBuilder('iw')
->leftJoin('iw.items', 'items')
->leftJoin('iw.payments', 'payments')
->leftJoin('iw.documents', 'documents')
->leftJoin('iw.stages', 'stages')
->leftJoin('iw.shipping', 'shipping')
->leftJoin('iw.customs', 'customs')
->addSelect('items', 'payments', 'documents', 'stages', 'shipping', 'customs')
->andWhere('iw.id = :id')
->andWhere('iw.business = :businessId')
->setParameter('id', $id)
->setParameter('businessId', $businessId)
->getQuery()
->getOneOrNullResult();
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Repository;
use App\Entity\ImportWorkflowShipping;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ImportWorkflowShippingRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ImportWorkflowShipping::class);
}
public function findByImportWorkflow($importWorkflowId)
{
return $this->createQueryBuilder('iws')
->andWhere('iws.importWorkflow = :importWorkflowId')
->setParameter('importWorkflowId', $importWorkflowId)
->orderBy('iws.dateSubmit', 'ASC')
->getQuery()
->getResult();
}
public function findByType($type, $importWorkflowId)
{
return $this->createQueryBuilder('iws')
->andWhere('iws.type = :type')
->andWhere('iws.importWorkflow = :importWorkflowId')
->setParameter('type', $type)
->setParameter('importWorkflowId', $importWorkflowId)
->getQuery()
->getOneOrNullResult();
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Repository;
use App\Entity\ImportWorkflowStage;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ImportWorkflowStageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ImportWorkflowStage::class);
}
public function findByImportWorkflow($importWorkflowId)
{
return $this->createQueryBuilder('iws')
->andWhere('iws.importWorkflow = :importWorkflowId')
->setParameter('importWorkflowId', $importWorkflowId)
->orderBy('iws.dateSubmit', 'ASC')
->getQuery()
->getResult();
}
public function findByStage($stage, $importWorkflowId)
{
return $this->createQueryBuilder('iws')
->andWhere('iws.stage = :stage')
->andWhere('iws.importWorkflow = :importWorkflowId')
->setParameter('stage', $stage)
->setParameter('importWorkflowId', $importWorkflowId)
->getQuery()
->getOneOrNullResult();
}
}

View file

@ -22,7 +22,8 @@ class PlugWarrantySerialRepository extends ServiceEntityRepository
public function findByBusiness($bid): array
{
return $this->createQueryBuilder('p')
->andWhere('p.bid = :val')
->join('p.business', 'b')
->andWhere('b.id = :val')
->setParameter('val', $bid)
->orderBy('p.dateSubmit', 'DESC')
->getQuery()
@ -36,7 +37,7 @@ class PlugWarrantySerialRepository extends ServiceEntityRepository
public function findByCommodity($bid, $commodityId): array
{
return $this->createQueryBuilder('p')
->andWhere('p.bid = :bid')
->andWhere('p.business = :bid')
->andWhere('p.commodity = :commodityId')
->setParameter('bid', $bid)
->setParameter('commodityId', $commodityId)
@ -52,7 +53,7 @@ class PlugWarrantySerialRepository extends ServiceEntityRepository
public function findByStatus($bid, $status): array
{
return $this->createQueryBuilder('p')
->andWhere('p.bid = :bid')
->andWhere('p.business = :bid')
->andWhere('p.status = :status')
->setParameter('bid', $bid)
->setParameter('status', $status)
@ -72,7 +73,7 @@ class PlugWarrantySerialRepository extends ServiceEntityRepository
->setParameter('serialNumber', $serialNumber);
if ($bid) {
$qb->andWhere('p.bid = :bid')
$qb->andWhere('p.business = :bid')
->setParameter('bid', $bid);
}
@ -86,7 +87,7 @@ class PlugWarrantySerialRepository extends ServiceEntityRepository
{
return $this->createQueryBuilder('p')
->leftJoin('p.commodity', 'c')
->andWhere('p.bid = :bid')
->andWhere('p.business = :bid')
->andWhere('p.serialNumber LIKE :keyword OR c.name LIKE :keyword OR p.description LIKE :keyword')
->setParameter('bid', $bid)
->setParameter('keyword', '%' . $keyword . '%')

View file

@ -50,9 +50,10 @@ class Access
return false;
}
}
elseif($this->request->headers->get('api-key')){
elseif($this->request->headers->get('api-key') || $this->request->headers->get('X-AUTH-TOKEN')){
$rawToken = $this->request->headers->get('api-key') ?: $this->request->headers->get('X-AUTH-TOKEN');
$token = $this->em->getRepository(APIToken::class)->findOneBy([
'token'=>$this->request->headers->get('api-key')
'token'=> $rawToken
]);
if(!$token) { return false; }
@ -68,6 +69,10 @@ class Access
$bid = $token->getBid();
}
else {
// بدون BID فعال یا توکن معتبر
return false;
}
if ($this->request->headers->get('activeYear')) {
$year = $this->em->getRepository(Year::class)->findOneBy([
'id' => $this->request->headers->get('activeYear'),
@ -75,7 +80,7 @@ class Access
]);
if (!$year) { return false; }
}
elseif($this->request->headers->get('api-key')){
elseif($this->request->headers->get('api-key') || $this->request->headers->get('X-AUTH-TOKEN')){
$year = $this->em->getRepository(Year::class)->findOneBy([
'head' => true,
'bid'=>$bid
@ -126,6 +131,19 @@ class Access
elseif ($this->user && $roll == 'join' && count($this->em->getRepository(Permission::class)->findBy(['user'=>$this->user,'bid'=>$bid]))){
return $accessArray;
}
$warehousePermission = $this->em->getRepository(Permission::class)->findOneBy([
'bid'=>$bid,
'user'=>$this->user
]);
if($warehousePermission && $warehousePermission->isWarehouseManager()){
$warehouseRoles = ['commodity', 'store', 'plugWarrantyManager'];
if(in_array($roll, $warehouseRoles)){
return $accessArray;
}
}
$methodName = 'is' . ucfirst($roll);
$permission = $this->em->getRepository(Permission::class)->findOneBy([
'bid'=>$bid,

View file

@ -171,6 +171,10 @@ class Explore
'amount' => $doc->getAmount(),
'mdate' => '',
'plugin' => $doc->getPlugin(),
// Approval fields
'isPreview' => $doc->isPreview(),
'isApproved' => $doc->isApproved(),
'approvedBy' => $doc->getApprovedBy() ? self::ExploreUser($doc->getApprovedBy()) : null,
];
}
@ -210,6 +214,9 @@ class Explore
$row->setDiscount(0);
$temp['tax'] = $row->getTax();
$temp['discount'] = $row->getDiscount();
// Approval fields - از سند اصلی گرفته می‌شود
return $temp;
}
@ -347,6 +354,7 @@ class Explore
'address' => $person->getAddress(),
'prelabel' => null,
'tags' => $person->getTags(),
'requireTwoStep' => $person->getBid() ? $person->getBid()->isRequireTwoStepApproval() : false,
];
if ($person->getPrelabel()) {
$res['prelabel'] = $person->getPrelabel()->getLabel();
@ -570,6 +578,10 @@ class Explore
'shortlinks' => $item->isShortlinks(),
'walletEnabled' => $item->isWalletEnable(),
'walletMatchBank' => $item->getWalletMatchBank() ? $item->getWalletMatchBank()->getId() : null,
'requireTwoStepApproval' => $item->isRequireTwoStepApproval(),
'invoiceApprover' => $item->getInvoiceApprover(),
'warehouseApprover' => $item->getWarehouseApprover(),
'financialApprover' => $item->getFinancialApprover(),
'updateSellPrice' => $item->isCommodityUpdateSellPriceAuto(),
'updateBuyPrice' => $item->isCommodityUpdateBuyPriceAuto(),
];

View file

@ -0,0 +1,42 @@
<?php
namespace App\Service;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpKernel\KernelInterface;
class FileStorage
{
public function __construct(private KernelInterface $kernel)
{
}
public function store(UploadedFile $file, string $businessId, string $context = 'general'): array
{
$safeOriginal = preg_replace('/[^A-Za-z0-9_.-]/', '_', $file->getClientOriginalName());
$relativeDir = 'storage/' . trim($businessId) . '/' . trim($context);
$absDir = rtrim($this->kernel->getProjectDir(), '/').'/var/' . $relativeDir;
if (!is_dir($absDir)) {
@mkdir($absDir, 0775, true);
}
$newName = uniqid('f_', true) . '_' . $safeOriginal;
$file->move($absDir, $newName);
$absPath = $absDir . '/' . $newName;
$size = @filesize($absPath) ?: null;
$mime = function_exists('mime_content_type') ? @mime_content_type($absPath) : null;
return [
'relativePath' => $relativeDir . '/' . $newName,
'originalName' => $file->getClientOriginalName(),
'size' => $size,
'mime' => $mime ?: $file->getClientMimeType(),
];
}
public function absolutePath(string $relativePath): string
{
$relativePath = ltrim($relativePath, '/');
return rtrim($this->kernel->getProjectDir(), '/').'/var/' . $relativePath;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -41,6 +41,7 @@
"maz-ui": "^3.50.1",
"monaco-editor": "^0.52.2",
"pinia": "^3.0.2",
"qr-scanner": "^1.4.2",
"sweetalert2": "^11.4.8",
"v-money3": "^3.24.1",
"vue": "^3.5.13",

View file

@ -0,0 +1,268 @@
<template>
<div class="approval-manager">
<!-- دکمه تأیید -->
<v-btn
v-if="showApprovalButton"
:color="approvalButtonColor"
size="small"
variant="outlined"
@click="showApprovalDialog = true"
:loading="processing"
:disabled="processing"
>
<v-icon size="small" class="me-1">
{{ approvalButtonIcon }}
</v-icon>
{{ approvalButtonText }}
</v-btn>
<!-- دکمه رد -->
<v-btn
v-if="showRejectButton"
color="error"
size="small"
variant="outlined"
@click="showRejectDialog = true"
:loading="processing"
:disabled="processing"
class="ms-2"
>
<v-icon size="small" class="me-1">mdi-close</v-icon>
رد سند
</v-btn>
<!-- دیالوگ تأیید -->
<v-dialog v-model="showApprovalDialog" max-width="500">
<v-card>
<v-card-title class="text-h6">
<v-icon color="success" class="me-2">mdi-check-circle</v-icon>
تأیید سند
</v-card-title>
<v-card-text>
<p>آیا از تأیید این سند اطمینان دارید؟</p>
<div class="mt-3">
<strong>نوع سند:</strong> {{ documentTypeText }}
</div>
<div class="mt-1">
<strong>شماره سند:</strong> {{ documentNumber }}
</div>
<div class="mt-1">
<strong>مبلغ:</strong> {{ formatCurrency(document.amount || 0) }}
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" @click="showApprovalDialog = false">انصراف</v-btn>
<v-btn
color="success"
@click="handleApproval"
:loading="processing"
>
تأیید
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- دیالوگ رد -->
<v-dialog v-model="showRejectDialog" max-width="500">
<v-card>
<v-card-title class="text-h6">
<v-icon color="error" class="me-2">mdi-close-circle</v-icon>
رد سند
</v-card-title>
<v-card-text>
<p>لطفاً دلیل رد این سند را وارد کنید:</p>
<v-textarea
v-model="rejectionReason"
label="دلیل رد"
variant="outlined"
rows="3"
:rules="[v => !!v || 'دلیل رد الزامی است']"
required
></v-textarea>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" @click="showRejectDialog = false">انصراف</v-btn>
<v-btn
color="error"
@click="handleRejection"
:loading="processing"
:disabled="!rejectionReason"
>
رد سند
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar برای نمایش پیامها -->
<v-snackbar
v-model="showSnackbar"
:color="snackbarColor"
:timeout="3000"
location="bottom"
>
{{ snackbarText }}
<template v-slot:actions>
<v-btn icon variant="text" @click="showSnackbar = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import {
shouldShowApprovalButton,
getApprovalButtonText,
getDocumentType
} from '@/utils/approvalUtils'
const props = defineProps({
document: {
type: Object,
required: true
},
businessSettings: {
type: Object,
required: true
},
currentUserEmail: {
type: String,
required: true
},
isBusinessOwner: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['approve', 'reject'])
const showApprovalDialog = ref(false)
const showRejectDialog = ref(false)
const rejectionReason = ref('')
const processing = ref(false)
const showSnackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
// محاسبه نمایش دکمهها
const showApprovalButton = computed(() => {
return shouldShowApprovalButton(
props.businessSettings,
props.document,
props.currentUserEmail,
props.isBusinessOwner
)
})
const showRejectButton = computed(() => {
// فقط مدیر کسب و کار میتواند سند را رد کند
return props.isBusinessOwner && props.document.approved !== false
})
// محاسبه متن و رنگ دکمه تأیید
const approvalButtonText = computed(() => getApprovalButtonText(props.document))
const approvalButtonColor = computed(() => {
if (props.document.approved === false) return 'warning'
return 'success'
})
const approvalButtonIcon = computed(() => {
if (props.document.approved === false) return 'mdi-refresh'
return 'mdi-check'
})
// محاسبه نوع سند
const documentType = computed(() => getDocumentType(props.document))
const documentTypeText = computed(() => {
switch (documentType.value) {
case 'invoice':
return 'فاکتور فروش'
case 'warehouse':
return 'حواله انبار'
case 'financial':
return 'سند مالی'
default:
return 'سند'
}
})
const documentNumber = computed(() => {
return props.document.invoiceNumber ||
props.document.warehouseNumber ||
props.document.documentNumber ||
props.document.id ||
'نامشخص'
})
// مدیریت تأیید
const handleApproval = async () => {
try {
processing.value = true
showApprovalDialog.value = false
await emit('approve', props.document)
showSnackbarText('سند با موفقیت تأیید شد', 'success')
} catch (error) {
console.error('خطا در تأیید سند:', error)
showSnackbarText('خطا در تأیید سند', 'error')
} finally {
processing.value = false
}
}
// مدیریت رد
const handleRejection = async () => {
try {
processing.value = true
showRejectDialog.value = false
await emit('reject', {
document: props.document,
reason: rejectionReason.value
})
rejectionReason.value = ''
showSnackbarText('سند با موفقیت رد شد', 'success')
} catch (error) {
console.error('خطا در رد سند:', error)
showSnackbarText('خطا در رد سند', 'error')
} finally {
processing.value = false
}
}
// نمایش پیام
const showSnackbarText = (text, color = 'success') => {
snackbarText.value = text
snackbarColor.value = color
showSnackbar.value = true
}
// فرمت مبلغ
const formatCurrency = (amount) => {
return new Intl.NumberFormat('fa-IR').format(amount) + ' ریال'
}
</script>
<style scoped>
.approval-manager {
display: flex;
align-items: center;
gap: 8px;
}
.approval-manager .v-btn {
text-transform: none;
}
</style>

View file

@ -0,0 +1,163 @@
<template>
<div class="approval-status">
<!-- نمایش وضعیت تأیید -->
<v-chip
:color="statusColor"
size="small"
class="me-2"
>
<v-icon size="small" class="me-1">
{{ statusIcon }}
</v-icon>
{{ statusText }}
</v-chip>
<!-- دکمه تأیید (فقط اگر کاربر مجوز داشته باشد) -->
<v-btn
v-if="showApprovalButton"
:color="approvalButtonColor"
size="small"
variant="outlined"
@click="handleApproval"
:loading="approving"
:disabled="approving"
>
<v-icon size="small" class="me-1">
{{ approvalButtonIcon }}
</v-icon>
{{ approvalButtonText }}
</v-btn>
<!-- اطلاعات تأییدکننده -->
<div v-if="document.approvedBy" class="text-caption text-medium-emphasis mt-1">
تایید شده توسط: {{ document.approvedBy.name || document.approvedBy }}
<span v-if="document.approvedAt">
در تاریخ: {{ formatDate(document.approvedAt) }}
</span>
</div>
<!-- اطلاعات ردکننده -->
<div v-if="document.rejectedBy" class="text-caption text-error mt-1">
رد شده توسط: {{ document.rejectedBy.name || document.rejectedBy }}
<span v-if="document.rejectedAt">
در تاریخ: {{ formatDate(document.rejectedAt) }}
</span>
<span v-if="document.rejectionReason" class="ms-2">
دلیل: {{ document.rejectionReason }}
</span>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import {
getApprovalStatusText,
getApprovalStatusColor,
shouldShowApprovalButton,
getApprovalButtonText,
getDocumentType
} from '@/utils/approvalUtils'
const props = defineProps({
document: {
type: Object,
required: true
},
businessSettings: {
type: Object,
required: true
},
currentUserEmail: {
type: String,
required: true
},
isBusinessOwner: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['approve', 'reject'])
const approving = ref(false)
// محاسبه وضعیت تأیید
const statusText = computed(() => getApprovalStatusText(props.document))
const statusColor = computed(() => getApprovalStatusColor(props.document))
const statusIcon = computed(() => {
if (props.document.approved === true) return 'mdi-check-circle'
if (props.document.approved === false) return 'mdi-close-circle'
return 'mdi-clock-outline'
})
// محاسبه دکمه تأیید
const showApprovalButton = computed(() => {
return shouldShowApprovalButton(
props.businessSettings,
props.document,
props.currentUserEmail,
props.isBusinessOwner
)
})
const approvalButtonText = computed(() => getApprovalButtonText(props.document))
const approvalButtonColor = computed(() => {
if (props.document.approved === false) return 'warning'
return 'success'
})
const approvalButtonIcon = computed(() => {
if (props.document.approved === false) return 'mdi-refresh'
return 'mdi-check'
})
// مدیریت تأیید
const handleApproval = async () => {
try {
approving.value = true
// اگر سند رد شده، برای تأیید مجدد ارسال کن
if (props.document.approved === false) {
await emit('approve', props.document)
} else {
// تأیید عادی
await emit('approve', props.document)
}
} catch (error) {
console.error('خطا در تأیید سند:', error)
} finally {
approving.value = false
}
}
// فرمت تاریخ
const formatDate = (date) => {
if (!date) return ''
try {
const dateObj = new Date(date)
if (isNaN(dateObj.getTime())) return date
return dateObj.toLocaleDateString('fa-IR')
} catch (error) {
return date
}
}
</script>
<style scoped>
.approval-status {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.approval-status .v-chip {
font-weight: 500;
}
.approval-status .v-btn {
text-transform: none;
}
</style>

View file

@ -0,0 +1,306 @@
<template>
<v-dialog v-model="dialog" max-width="800" persistent>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="ml-2">mdi-plus</v-icon>
پرونده واردات جدید
</v-card-title>
<v-divider></v-divider>
<v-form ref="form" v-model="valid" @submit.prevent="create" validate-on="input">
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.title"
label="عنوان پرونده"
:rules="[rules.required, rules.minLength]"
required
counter="100"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.supplierName"
label="نام تامین کننده"
:rules="[rules.required, rules.minLength]"
required
counter="100"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.supplierCountry"
label="کشور تامین کننده"
:rules="[rules.minLength]"
counter="50"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.supplierPhone"
label="تلفن تامین کننده"
:rules="[rules.phone]"
counter="20"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
v-model="formData.supplierEmail"
label="ایمیل تامین کننده"
type="email"
:rules="[rules.email]"
counter="100"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="formData.supplierAddress"
label="آدرس تامین کننده"
rows="2"
:rules="[rules.maxLength]"
counter="500"
></v-textarea>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-text-field
class="ltr-input"
:model-value="formatMoney(formData.totalAmount)"
label="مبلغ کل"
type="text"
inputmode="numeric"
:rules="[rules.positiveMoney, rules.maxAmount]"
@update:modelValue="onMoneyInput('totalAmount', $event)"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="formData.currency"
:items="currencyOptions"
label="واحد پول"
:rules="[rules.required]"
required
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-text-field
class="ltr-input"
:model-value="formatMoney(formData.exchangeRate)"
label="نرخ تبدیل"
type="text"
inputmode="numeric"
:rules="[rules.positiveMoney, rules.maxExchangeRate]"
@update:modelValue="onMoneyInput('exchangeRate', $event)"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
:model-value="formatMoney(formData.totalAmountIRR)"
label="مبلغ کل (ریال)"
readonly
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="formData.description"
label="توضیحات"
rows="3"
:rules="[rules.maxLength]"
counter="1000"
></v-textarea>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="cancel">لغو</v-btn>
<v-btn
type="submit"
color="primary"
:loading="loading"
:disabled="!valid"
>
ایجاد
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import Swal from 'sweetalert2'
// Props & Emits
const props = defineProps({
modelValue: Boolean
})
const emit = defineEmits(['update:modelValue', 'created'])
// Data
const form = ref()
const valid = ref(false)
const loading = ref(false)
const formData = ref({
title: '',
supplierName: '',
supplierCountry: '',
supplierPhone: '',
supplierEmail: '',
supplierAddress: '',
totalAmount: '',
currency: 'USD',
exchangeRate: '',
totalAmountIRR: '',
description: ''
})
// Computed
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// Currency options
const currencyOptions = [
{ title: 'دلار آمریکا (USD)', value: 'USD' },
{ title: 'یورو (EUR)', value: 'EUR' },
{ title: 'پوند انگلیس (GBP)', value: 'GBP' },
{ title: 'یوان چین (CNY)', value: 'CNY' },
{ title: 'درهم امارات (AED)', value: 'AED' },
{ title: 'ریال (IRR)', value: 'IRR' }
]
// Validation rules
const rules = {
required: (value) => !!value || 'این فیلد الزامی است',
minLength: (value) => !value || value.length >= 2 || 'حداقل 2 کاراکتر الزامی است',
maxLength: (value) => !value || value.length <= 1000 || 'حداکثر 1000 کاراکتر مجاز است',
email: (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || 'ایمیل معتبر وارد کنید',
phone: (value) => !value || /^[\d\-\+\(\)\s]+$/.test(value) || 'شماره تلفن معتبر وارد کنید',
positive: (value) => !value || parseFloat(value) > 0 || 'مقدار باید مثبت باشد',
positiveMoney: (value) => {
const numeric = parseMoneyInput(value)
return numeric > 0 || 'مقدار باید مثبت باشد'
},
maxAmount: (value) => !value || parseFloat(value) <= 999999999 || 'مبلغ نباید بیشتر از 999,999,999 باشد',
maxExchangeRate: (value) => !value || parseFloat(value) <= 999999 || 'نرخ تبدیل نباید بیشتر از 999,999 باشد'
}
const parseMoneyInput = (val) => {
if (val === null || val === undefined) return 0
const cleaned = String(val).replace(/,/g, '').replace(/[^\d.-]/g, '')
const num = Number(cleaned)
return Number.isFinite(num) ? num : 0
}
const onMoneyInput = (field, value) => {
const numeric = parseMoneyInput(value)
formData.value[field] = numeric
}
const formatMoney = (value) => {
const numericValue = Number(value) || 0
return numericValue
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
watch([
() => formData.value.totalAmount,
() => formData.value.exchangeRate,
() => formData.value.currency
], ([newTotalAmount, newExchangeRate, currency]) => {
const total = parseMoneyInput(newTotalAmount)
const rate = currency === 'IRR' ? 1 : parseMoneyInput(newExchangeRate)
const result = Math.round(total * rate)
formData.value.totalAmountIRR = isNaN(result) ? 0 : result
}, { immediate: true })
// Methods
const create = async () => {
if (!valid.value) return
loading.value = true
try {
const response = await axios.post('/api/import-workflow/create', formData.value)
if (response.data.Success) {
Swal.fire({
title: 'موفق',
text: 'پرونده واردات با موفقیت ایجاد شد',
icon: 'success'
})
resetForm()
emit('created')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error creating workflow:', error)
Swal.fire({
title: 'خطا',
text: 'در ایجاد پرونده واردات خطایی رخ داد',
icon: 'error'
})
} finally {
loading.value = false
}
}
const cancel = () => {
resetForm()
dialog.value = false
}
const resetForm = () => {
formData.value = {
title: '',
supplierName: '',
supplierCountry: '',
supplierPhone: '',
supplierEmail: '',
supplierAddress: '',
totalAmount: '',
currency: 'USD',
exchangeRate: '',
totalAmountIRR: '',
description: ''
}
if (form.value) {
form.value.reset()
}
}
</script>
<style scoped>
.ltr-input :deep(input) {
direction: ltr !important;
text-align: left !important;
}
</style>

View file

@ -0,0 +1,423 @@
<template>
<div class="import-workflow-customs">
<v-card-text>
<div class="d-flex justify-space-between align-center mb-4">
<h3>اطلاعات ترخیص گمرکی</h3>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showAddDialog = true"
>
افزودن اطلاعات ترخیص
</v-btn>
</div>
<v-data-table
:headers="headers"
:items="customs"
:loading="loading"
density="comfortable"
class="elevation-1"
:header-props="{ class: 'custom-header' }"
no-data-text="اطلاعات ترخیص گمرکی ثبت نشده است"
>
<template v-slot:item.totalCustomsCharges="{ item }">
<div>
{{ formatNumber(item.totalCustomsCharges) }}
<small class="text-medium-emphasis">ریال</small>
</div>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
@click="editCustoms(item)"
></v-btn>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="deleteCustoms(item)"
></v-btn>
</template>
</v-data-table>
</v-card-text>
<!-- Add/Edit Dialog -->
<v-dialog v-model="showAddDialog" max-width="800" persistent>
<v-card>
<v-card-title class="pa-4">
{{ editingCustoms ? 'ویرایش اطلاعات ترخیص' : 'افزودن اطلاعات ترخیص جدید' }}
</v-card-title>
<v-divider></v-divider>
<v-form ref="form" v-model="valid" @submit.prevent="saveCustoms">
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.declarationNumber"
label="شماره اظهارنامه"
:rules="[rules.required]"
required
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.customsCode"
label="کد گمرک"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<h-date-picker
v-model="formData.clearanceDate"
label="تاریخ ترخیص"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.customsBroker"
label="ترخیصکار"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-text-field
class="ltr-input"
:model-value="formatMoney(formData.customsDuty)"
label="حقوق گمرکی"
type="text"
inputmode="numeric"
:rules="[rules.positiveMoney]"
@update:modelValue="onMoneyInput('customsDuty', $event)"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field
class="ltr-input"
:model-value="formatMoney(formData.valueAddedTax)"
label="مالیات بر ارزش افزوده"
type="text"
inputmode="numeric"
:rules="[rules.positiveMoney]"
@update:modelValue="onMoneyInput('valueAddedTax', $event)"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field
class="ltr-input"
:model-value="formatMoney(formData.otherCharges)"
label="سایر عوارض"
type="text"
inputmode="numeric"
:rules="[rules.positiveMoney]"
@update:modelValue="onMoneyInput('otherCharges', $event)"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
:model-value="formatMoney(totalChargesNumeric)"
label="کل هزینه‌های گمرکی"
readonly
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.warehouseNumber"
label="شماره انبار"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.customsBrokerPhone"
label="تلفن ترخیصکار"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.customsBrokerEmail"
label="ایمیل ترخیصکار"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
v-model="formData.warehouseLocation"
label="محل انبار"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="formData.description"
label="توضیحات"
rows="2"
></v-textarea>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn @click="closeDialog">لغو</v-btn>
<v-btn
type="submit"
color="primary"
:loading="saveLoading"
:disabled="!valid"
>
{{ editingCustoms ? 'ویرایش' : 'افزودن' }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import Swal from 'sweetalert2'
import HDatepicker from '@/components/forms/Hdatepicker.vue'
// Props
const props = defineProps({
workflowId: {
type: [String, Number],
required: true
},
customs: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['updated'])
// Data
const loading = ref(false)
const showAddDialog = ref(false)
const editingCustoms = ref(null)
const form = ref()
const valid = ref(false)
const saveLoading = ref(false)
const formData = ref({
declarationNumber: '',
customsCode: '',
clearanceDate: '',
customsDuty: '',
valueAddedTax: '',
otherCharges: '',
totalCustomsCharges: '',
customsBroker: '',
customsBrokerPhone: '',
customsBrokerEmail: '',
warehouseNumber: '',
warehouseLocation: '',
description: ''
})
// Headers
const headers = [
{ title: 'شماره اظهارنامه', key: 'declarationNumber', sortable: false },
{ title: 'کد گمرک', key: 'customsCode', sortable: false },
{ title: 'تاریخ ترخیص', key: 'clearanceDate', sortable: false },
{ title: 'ترخیصکار', key: 'customsBroker', sortable: false },
{ title: 'کل هزینه‌ها', key: 'totalCustomsCharges', sortable: false },
{ title: 'عملیات', key: 'actions', sortable: false, align: 'center' }
]
// Validation rules
const rules = {
required: (value) => !!value || 'این فیلد الزامی است',
positive: (value) => !value || parseFloat(value) > 0 || 'مقدار باید مثبت باشد',
positiveMoney: (value) => {
const numeric = parseMoneyInput(value)
return numeric >= 0 || 'مقدار باید مثبت باشد'
}
}
// Helpers for money formatting/parse and LTR input
const parseMoneyInput = (val) => {
if (val === null || val === undefined) return 0
const cleaned = String(val).replace(/,/g, '').replace(/[^\d.-]/g, '')
const num = Number(cleaned)
return Number.isFinite(num) ? num : 0
}
const onMoneyInput = (field, value) => {
const numeric = parseMoneyInput(value)
formData.value[field] = numeric
}
const formatMoney = (value) => {
const numericValue = Number(value) || 0
return numericValue
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
// Computed
const totalChargesNumeric = computed(() => {
const duty = parseFloat(formData.value.customsDuty) || 0
const vat = parseFloat(formData.value.valueAddedTax) || 0
const other = parseFloat(formData.value.otherCharges) || 0
const total = duty + vat + other
formData.value.totalCustomsCharges = total.toString()
return total
})
// Methods
const editCustoms = (customs) => {
editingCustoms.value = customs
formData.value = { ...customs }
showAddDialog.value = true
}
const deleteCustoms = async (customs) => {
const result = await Swal.fire({
title: 'حذف اطلاعات ترخیص',
text: 'آیا از حذف این اطلاعات ترخیص گمرکی اطمینان دارید؟',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'لغو'
})
if (result.isConfirmed) {
try {
const response = await axios.delete(`/api/import-workflow/${props.workflowId}/customs/${customs.id}/delete`)
if (response.data.Success) {
Swal.fire('موفق', 'اطلاعات ترخیص با موفقیت حذف شد', 'success')
emit('updated')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
Swal.fire('خطا', 'در حذف اطلاعات ترخیص خطایی رخ داد', 'error')
}
}
}
const saveCustoms = async () => {
if (!valid.value) return
saveLoading.value = true
try {
const url = editingCustoms.value
? `/api/import-workflow/${props.workflowId}/customs/${editingCustoms.value.id}/update`
: `/api/import-workflow/${props.workflowId}/customs/create`
const method = editingCustoms.value ? 'PUT' : 'POST'
const response = await axios({
method,
url,
data: formData.value
})
if (response.data.Success) {
Swal.fire({
title: 'موفق',
text: editingCustoms.value ? 'اطلاعات ترخیص با موفقیت ویرایش شد' : 'اطلاعات ترخیص با موفقیت افزوده شد',
icon: 'success'
})
closeDialog()
emit('updated')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error saving customs:', error)
Swal.fire({
title: 'خطا',
text: 'در ذخیره اطلاعات ترخیص خطایی رخ داد',
icon: 'error'
})
} finally {
saveLoading.value = false
}
}
const closeDialog = () => {
editingCustoms.value = null
formData.value = {
declarationNumber: '',
customsCode: '',
clearanceDate: '',
customsDuty: '',
valueAddedTax: '',
otherCharges: '',
totalCustomsCharges: '',
customsBroker: '',
customsBrokerPhone: '',
customsBrokerEmail: '',
warehouseNumber: '',
warehouseLocation: '',
description: ''
}
showAddDialog.value = false
if (form.value) {
form.value.reset()
}
}
// Utilities
const formatNumber = (number) => {
if (!number) return '0'
return new Intl.NumberFormat('fa-IR').format(number)
}
</script>
<style scoped>
.import-workflow-customs {
direction: rtl;
}
:deep(.v-data-table-header th) {
background-color: #f5f5f5 !important;
font-weight: bold !important;
color: #333 !important;
}
:deep(.v-data-table__wrapper table td) {
padding: 12px 16px !important;
border-bottom: 1px solid #e0e0e0 !important;
}
:deep(.v-data-table__wrapper table tr:hover) {
background-color: #f8f9fa !important;
}
:deep(.v-chip) {
font-weight: 500;
}
.custom-header {
background-color: #f5f5f5 !important;
}
</style>

View file

@ -0,0 +1,460 @@
<template>
<div class="import-workflow-documents">
<v-card-text>
<div class="d-flex justify-space-between align-center mb-4">
<h3>مدیریت اسناد</h3>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showAddDialog = true"
>
افزودن سند
</v-btn>
</div>
<v-data-table
:headers="headers"
:items="documents"
:loading="loading"
density="comfortable"
class="elevation-1"
:header-props="{ class: 'custom-header' }"
no-data-text="سندی ثبت نشده است"
>
<template v-slot:item.type="{ item }">
<v-chip
:color="getTypeColor(item.type)"
size="small"
variant="flat"
>
{{ getTypeText(item.type) }}
</v-chip>
</template>
<template v-slot:item.fileName="{ item }">
<div class="d-flex align-center">
<v-icon class="ml-1">{{ getFileIcon(item.fileType) }}</v-icon>
<span>{{ item.fileName || '-' }}</span>
</div>
</template>
<template v-slot:item.fileSize="{ item }">
{{ formatFileSize(item.fileSize) }}
</template>
<template v-slot:item.actions="{ item }">
<v-btn
v-if="item.id"
icon="mdi-download"
size="small"
variant="text"
@click="downloadFile(item)"
></v-btn>
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
@click="editDocument(item)"
></v-btn>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="deleteDocument(item)"
></v-btn>
</template>
</v-data-table>
</v-card-text>
<!-- Add/Edit Dialog -->
<v-dialog v-model="showAddDialog" max-width="600" persistent>
<v-card>
<v-card-title class="pa-4">
{{ editingDocument ? 'ویرایش سند' : 'افزودن سند جدید' }}
</v-card-title>
<v-divider></v-divider>
<v-form ref="form" v-model="valid" @submit.prevent="saveDocument">
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="formData.type"
:items="documentTypes"
label="نوع سند"
:rules="[rules.required]"
required
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.title"
label="عنوان سند"
:rules="[rules.required]"
required
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.documentNumber"
label="شماره سند"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<h-date-picker
v-model="formData.issueDate"
label="تاریخ صدور"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-file-input
:model-value="fileInputValue"
label="فایل سند"
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx"
prepend-icon="mdi-paperclip"
:multiple="false"
:rules="editingDocument ? [] : [rules.fileRequired]"
@update:modelValue="onFileChange"
></v-file-input>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="formData.description"
label="توضیحات"
rows="2"
></v-textarea>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn @click="closeDialog">لغو</v-btn>
<v-btn
type="submit"
color="primary"
:loading="saveLoading"
:disabled="!valid"
>
{{ editingDocument ? 'ویرایش' : 'افزودن' }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import Swal from 'sweetalert2'
import HDatepicker from '@/components/forms/Hdatepicker.vue'
// Props
const props = defineProps({
workflowId: {
type: [String, Number],
required: true
},
documents: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['updated'])
// Data
const loading = ref(false)
const showAddDialog = ref(false)
const editingDocument = ref(null)
const form = ref()
const valid = ref(false)
const saveLoading = ref(false)
const selectedFile = ref(null)
const fileInputValue = ref([])
const formData = ref({
type: '',
title: '',
documentNumber: '',
issueDate: '',
description: ''
})
// Headers
const headers = [
{ title: 'نوع سند', key: 'type', sortable: false },
{ title: 'عنوان', key: 'title', sortable: false },
{ title: 'فایل', key: 'fileName', sortable: false },
{ title: 'حجم', key: 'fileSize', sortable: false },
{ title: 'شماره سند', key: 'documentNumber', sortable: false },
{ title: 'عملیات', key: 'actions', sortable: false, align: 'center' }
]
// Document types
const documentTypes = [
{ title: 'فاکتور تجاری', value: 'commercial_invoice' },
{ title: 'پیش فاکتور', value: 'proforma_invoice' },
{ title: 'بارنامه', value: 'bill_of_lading' },
{ title: 'لیست بسته‌بندی', value: 'packing_list' },
{ title: 'گواهی مبدا', value: 'certificate_of_origin' },
{ title: 'گواهی کیفیت', value: 'quality_certificate' },
{ title: 'مجوز واردات', value: 'import_permit' },
{ title: 'اظهارنامه گمرکی', value: 'customs_declaration' },
{ title: 'رسید پرداخت', value: 'payment_receipt' },
{ title: 'سایر اسناد', value: 'other' }
]
// Validation rules
const rules = {
required: (value) => !!value || 'این فیلد الزامی است',
fileRequired: (value) => !!selectedFile.value || 'انتخاب فایل الزامی است'
}
// Methods
const editDocument = (document) => {
editingDocument.value = document
formData.value = { ...document }
showAddDialog.value = true
}
const deleteDocument = async (document) => {
const result = await Swal.fire({
title: 'حذف سند',
text: `آیا از حذف سند "${document.title}" اطمینان دارید؟`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'لغو'
})
if (result.isConfirmed) {
try {
const response = await axios.delete(`/api/import-workflow/${props.workflowId}/documents/${document.id}/delete`)
if (response.data.Success) {
Swal.fire('موفق', 'سند با موفقیت حذف شد', 'success')
emit('updated')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
Swal.fire('خطا', 'در حذف سند خطایی رخ داد', 'error')
}
}
}
const saveDocument = async () => {
if (!valid.value) return
saveLoading.value = true
try {
const formDataToSend = new FormData()
// Add form fields
Object.keys(formData.value).forEach(key => {
formDataToSend.append(key, formData.value[key] || '')
})
// Add file if selected
if (selectedFile.value) {
formDataToSend.append('file', selectedFile.value)
}
const url = editingDocument.value
? `/api/import-workflow/${props.workflowId}/documents/${editingDocument.value.id}/update`
: `/api/import-workflow/${props.workflowId}/documents/create`
const method = 'POST'
const response = await axios({
method,
url,
data: formDataToSend,
headers: {
'Content-Type': 'multipart/form-data'
}
})
if (response.data.Success) {
Swal.fire({
title: 'موفق',
text: editingDocument.value ? 'سند با موفقیت ویرایش شد' : 'سند با موفقیت افزوده شد',
icon: 'success'
})
closeDialog()
emit('updated')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error saving document:', error)
Swal.fire({
title: 'خطا',
text: 'در ذخیره سند خطایی رخ داد',
icon: 'error'
})
} finally {
saveLoading.value = false
}
}
const onFileChange = (val) => {
// Normalize input to single File
if (Array.isArray(val)) {
fileInputValue.value = val
selectedFile.value = val.length > 0 ? val[0] : null
} else {
fileInputValue.value = val ? [val] : []
selectedFile.value = val || null
}
}
const downloadFile = async (doc) => {
try {
const url = `/api/import-workflow/documents/${doc.id}/download`
const res = await axios.get(url, { responseType: 'blob' })
// Try to extract filename from headers
const cd = (res.headers && (res.headers['content-disposition'] || res.headers['Content-Disposition'])) || ''
let filename = doc.fileName || `document-${doc.id}`
const match = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(cd)
if (match) {
filename = decodeURIComponent(match[1] || match[2] || filename)
}
const blob = new Blob([res.data], { type: res.headers['content-type'] || 'application/octet-stream' })
const blobUrl = window.URL.createObjectURL(blob)
const link = window.document.createElement('a')
link.href = blobUrl
link.download = filename
window.document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(blobUrl)
} catch (e) {
Swal.fire('خطا', 'دانلود فایل ناموفق بود', 'error')
}
}
const closeDialog = () => {
editingDocument.value = null
selectedFile.value = null
fileInputValue.value = []
formData.value = {
type: '',
title: '',
documentNumber: '',
issueDate: '',
description: ''
}
showAddDialog.value = false
if (form.value) {
form.value.reset()
}
}
// Utilities
const getTypeColor = (type) => {
const colors = {
commercial_invoice: 'blue',
proforma_invoice: 'light-blue',
bill_of_lading: 'purple',
packing_list: 'teal',
certificate_of_origin: 'green',
quality_certificate: 'lime',
import_permit: 'orange',
customs_declaration: 'red',
payment_receipt: 'pink',
other: 'grey'
}
return colors[type] || 'grey'
}
const getTypeText = (type) => {
const texts = {
commercial_invoice: 'فاکتور تجاری',
proforma_invoice: 'پیش فاکتور',
bill_of_lading: 'بارنامه',
packing_list: 'لیست بسته‌بندی',
certificate_of_origin: 'گواهی مبدا',
quality_certificate: 'گواهی کیفیت',
import_permit: 'مجوز واردات',
customs_declaration: 'اظهارنامه گمرکی',
payment_receipt: 'رسید پرداخت',
other: 'سایر'
}
return texts[type] || type
}
const getFileIcon = (fileType) => {
if (!fileType) return 'mdi-file'
if (fileType.includes('pdf')) return 'mdi-file-pdf-box'
if (fileType.includes('image')) return 'mdi-file-image'
if (fileType.includes('word')) return 'mdi-file-word'
if (fileType.includes('excel')) return 'mdi-file-excel'
return 'mdi-file'
}
const formatFileSize = (size) => {
if (!size) return '-'
const bytes = parseInt(size)
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
<style scoped>
.import-workflow-documents {
direction: rtl;
}
:deep(.v-data-table-header th) {
background-color: #f5f5f5 !important;
font-weight: bold !important;
color: #333 !important;
}
:deep(.v-data-table__wrapper table td) {
padding: 12px 16px !important;
border-bottom: 1px solid #e0e0e0 !important;
}
:deep(.v-data-table__wrapper table tr:hover) {
background-color: #f8f9fa !important;
}
:deep(.v-chip) {
font-weight: 500;
}
.custom-header {
background-color: #f5f5f5 !important;
}
</style>

View file

@ -0,0 +1,523 @@
<template>
<div class="import-workflow-items">
<v-card-text>
<div class="d-flex justify-space-between align-center mb-4">
<h3>آیتمهای وارداتی</h3>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showAddDialog = true"
>
افزودن آیتم
</v-btn>
</div>
<v-data-table
:headers="headers"
:items="items"
:loading="loading"
density="comfortable"
class="elevation-1"
:header-props="{ class: 'custom-header' }"
no-data-text="آیتمی ثبت نشده است"
>
<template v-slot:item.unitPrice="{ item }">
<div>
{{ formatNumber(item.unitPrice) }}
<small class="text-medium-emphasis">{{ getCurrency(item) }}</small>
</div>
</template>
<template v-slot:item.totalPrice="{ item }">
<div>
{{ formatNumber(item.totalPrice) }}
<small class="text-medium-emphasis">{{ getCurrency(item) }}</small>
</div>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
@click="editItem(item)"
></v-btn>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="deleteItem(item)"
></v-btn>
</template>
</v-data-table>
</v-card-text>
<!-- Add/Edit Dialog -->
<v-dialog v-model="showAddDialog" max-width="800" persistent>
<v-card>
<v-card-title class="pa-4">
{{ editingItem ? 'ویرایش آیتم' : 'افزودن آیتم جدید' }}
</v-card-title>
<v-divider></v-divider>
<v-form ref="form" v-model="valid" @submit.prevent="saveItem">
<v-card-text>
<!-- Commodity selector / viewer -->
<v-row>
<v-col cols="12">
<template v-if="!editingItem">
<Hcommoditysearch
v-model="selectedCommodity"
:return-object="true"
label="انتخاب کالا"
/>
</template>
<template v-else>
<v-text-field
:model-value="selectedCommodity ? (selectedCommodity.name + (selectedCommodity.code ? ` (${selectedCommodity.code})` : '')) : ''"
label="کالا"
variant="outlined"
density="compact"
disabled
/>
</template>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.brand"
label="برند"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.model"
label="مدل"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.originCountry"
label="کشور مبدا"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.quantity"
label="تعداد"
type="number"
:rules="[rules.required, rules.positive]"
required
min="1"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-text-field
class="ltr-input"
:model-value="formatMoney(formData.unitPrice)"
label="قیمت واحد (ارزی)"
type="text"
inputmode="numeric"
:rules="[rules.required, rules.positiveMoney]"
required
@update:modelValue="onMoneyInput('unitPrice', $event)"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field
class="ltr-input"
:model-value="formatMoney(formData.unitPriceIRR)"
label="قیمت واحد (ریال)"
type="text"
inputmode="numeric"
:rules="[rules.required, rules.positiveMoney]"
required
@update:modelValue="onMoneyInput('unitPriceIRR', $event)"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field
:model-value="(formData.quantity && formData.unitPrice) ? formatMoney(totalPrice) : ''"
label="قیمت کل"
readonly
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.weight"
label="وزن (کیلوگرم)"
type="number"
step="0.01"
:rules="[rules.positive]"
min="0"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.volume"
label="حجم (متر مکعب)"
type="number"
step="0.01"
:rules="[rules.positive]"
min="0"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="formData.specifications"
label="ویژگی‌ها"
rows="2"
></v-textarea>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="formData.description"
label="توضیحات"
rows="2"
></v-textarea>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn @click="closeDialog">لغو</v-btn>
<v-btn
type="submit"
color="primary"
:loading="loading"
:disabled="!valid"
>
{{ editingItem ? 'ویرایش' : 'افزودن' }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card>
<v-card-title>حذف آیتم</v-card-title>
<v-card-text>
آیا از حذف آیتم "{{ selectedItem?.name }}" اطمینان دارید؟
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="showDeleteDialog = false">لغو</v-btn>
<v-btn color="error" @click="confirmDelete" :loading="deleteLoading">حذف</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import Swal from 'sweetalert2'
import Hcommoditysearch from '@/components/forms/Hcommoditysearch.vue'
// Props
const props = defineProps({
workflowId: {
type: [String, Number],
required: true
},
items: {
type: Array,
default: () => []
},
currency: {
type: String,
default: 'USD'
}
})
const emit = defineEmits(['updated'])
// Data
const loading = ref(false)
const showAddDialog = ref(false)
const showDeleteDialog = ref(false)
const editingItem = ref(null)
const selectedItem = ref(null)
const form = ref()
const valid = ref(false)
const saveLoading = ref(false)
const deleteLoading = ref(false)
// Commodity selection
const selectedCommodity = ref(null)
watch(selectedCommodity, (val) => {
if (val) {
// Auto-fill name and product code from selected commodity
formData.value.name = val.name || ''
formData.value.productCode = val.code || ''
}
})
const formData = ref({
name: '',
productCode: '',
brand: '',
model: '',
originCountry: '',
quantity: '',
unitPrice: '',
unitPriceIRR: '',
totalPrice: '',
totalPriceIRR: '',
weight: '',
volume: '',
specifications: '',
description: ''
})
// Headers
const headers = [
{ title: 'نام کالا', key: 'name', sortable: false },
{ title: 'برند', key: 'brand', sortable: false },
{ title: 'مدل', key: 'model', sortable: false },
{ title: 'کشور مبدا', key: 'originCountry', sortable: false },
{ title: 'تعداد', key: 'quantity', sortable: false },
{ title: 'قیمت واحد', key: 'unitPrice', sortable: false },
{ title: 'قیمت کل', key: 'totalPrice', sortable: false },
{ title: 'عملیات', key: 'actions', sortable: false, align: 'center' }
]
// Validation rules
const rules = {
required: (value) => !!value || 'این فیلد الزامی است',
positive: (value) => !value || parseFloat(value) > 0 || 'مقدار باید مثبت باشد',
positiveMoney: (value) => {
const numeric = parseMoneyInput(value)
return numeric > 0 || 'مقدار باید مثبت باشد'
}
}
// Helpers for money formatting/parse and LTR input
const parseMoneyInput = (val) => {
if (val === null || val === undefined) return 0
const cleaned = String(val).replace(/,/g, '').replace(/[^\d.-]/g, '')
const num = Number(cleaned)
return Number.isFinite(num) ? num : 0
}
const onMoneyInput = (field, value) => {
const numeric = parseMoneyInput(value)
formData.value[field] = numeric
}
const formatMoney = (value) => {
const numericValue = Number(value) || 0
return numericValue
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
// Computed
const totalPrice = computed(() => {
if (formData.value.quantity && formData.value.unitPrice) {
const total = parseFloat(formData.value.quantity) * parseFloat(formData.value.unitPrice)
formData.value.totalPrice = total.toString()
return total
}
return 0
})
// Watch for unit price IRR and quantity changes
watch([() => formData.value.quantity, () => formData.value.unitPriceIRR], () => {
if (formData.value.quantity && formData.value.unitPriceIRR) {
const total = parseFloat(formData.value.quantity) * parseFloat(formData.value.unitPriceIRR)
formData.value.totalPriceIRR = total.toString()
}
})
// Methods
const editItem = (item) => {
editingItem.value = item
formData.value = { ...item }
showAddDialog.value = true
// set selected commodity from item if exists
if (item && item.commodity) {
selectedCommodity.value = item.commodity
} else {
selectedCommodity.value = null
}
}
const deleteItem = (item) => {
selectedItem.value = item
showDeleteDialog.value = true
}
const saveItem = async () => {
if (!valid.value) return
if (!selectedCommodity.value) {
Swal.fire({ title: 'هشدار', text: 'انتخاب کالا الزامی است', icon: 'warning' })
return
}
saveLoading.value = true
try {
const url = editingItem.value
? `/api/import-workflow/${props.workflowId}/items/${editingItem.value.id}/update`
: `/api/import-workflow/${props.workflowId}/items/create`
const method = editingItem.value ? 'PUT' : 'POST'
const payload = {
...formData.value,
commodity_id: selectedCommodity.value.id,
}
const response = await axios({
method,
url,
data: payload
})
if (response.data.Success) {
Swal.fire({
title: 'موفق',
text: editingItem.value ? 'آیتم با موفقیت ویرایش شد' : 'آیتم با موفقیت افزوده شد',
icon: 'success'
})
cancelEdit()
emit('updated')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error saving item:', error)
Swal.fire({
title: 'خطا',
text: 'در ذخیره آیتم خطایی رخ داد',
icon: 'error'
})
} finally {
saveLoading.value = false
}
}
const confirmDelete = async () => {
deleteLoading.value = true
try {
const response = await axios.delete(`/api/import-workflow/${props.workflowId}/items/${selectedItem.value.id}/delete`)
if (response.data.Success) {
Swal.fire({
title: 'موفق',
text: 'آیتم با موفقیت حذف شد',
icon: 'success'
})
emit('updated')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error deleting item:', error)
Swal.fire({
title: 'خطا',
text: 'در حذف آیتم خطایی رخ داد',
icon: 'error'
})
} finally {
deleteLoading.value = false
showDeleteDialog.value = false
}
}
const cancelEdit = () => {
editingItem.value = null
formData.value = {
name: '',
productCode: '',
brand: '',
model: '',
originCountry: '',
quantity: '',
unitPrice: '',
unitPriceIRR: '',
totalPrice: '',
totalPriceIRR: '',
weight: '',
volume: '',
specifications: '',
description: ''
}
showAddDialog.value = false
if (form.value) {
form.value.reset()
}
}
// Backward-compat handler for template bindings
const closeDialog = () => {
cancelEdit()
}
// Utilities
const formatNumber = (number) => {
if (!number) return '0'
return new Intl.NumberFormat('fa-IR').format(number)
}
const getCurrency = () => props.currency || 'USD'
</script>
<style scoped>
.import-workflow-items {
direction: rtl;
}
:deep(.v-data-table-header th) {
background-color: #f5f5f5 !important;
font-weight: bold !important;
color: #333 !important;
}
:deep(.v-data-table__wrapper table td) {
padding: 12px 16px !important;
border-bottom: 1px solid #e0e0e0 !important;
}
:deep(.v-data-table__wrapper table tr:hover) {
background-color: #f8f9fa !important;
}
:deep(.v-chip) {
font-weight: 500;
}
.custom-header {
background-color: #f5f5f5 !important;
}
</style>

View file

@ -0,0 +1,547 @@
<template>
<div class="import-workflow-payments">
<v-card-text>
<div class="d-flex justify-space-between align-center mb-4">
<h3>پرداختها</h3>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showAddDialog = true"
>
افزودن پرداخت
</v-btn>
</div>
<v-data-table
:headers="headers"
:items="payments"
:loading="loading"
density="comfortable"
class="elevation-1"
:header-props="{ class: 'custom-header' }"
no-data-text="پرداختی ثبت نشده است"
>
<template v-slot:item.type="{ item }">
<v-chip
:color="getTypeColor(item.type)"
size="small"
variant="flat"
>
{{ getTypeText(item.type) }}
</v-chip>
</template>
<template v-slot:item.amount="{ item }">
<div>
{{ formatNumber(item.amount) }}
<small class="text-medium-emphasis">{{ item.currency }}</small>
</div>
</template>
<template v-slot:item.amountIRR="{ item }">
<div>
{{ formatNumber(item.amountIRR) }}
<small class="text-medium-emphasis">ریال</small>
</div>
</template>
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
>
{{ getStatusText(item.status) }}
</v-chip>
</template>
<template v-slot:item.paymentDate="{ item }">
{{ formatDate(item.paymentDate) }}
</template>
<template v-slot:item.actions="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
@click="editPayment(item)"
></v-btn>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="deletePayment(item)"
></v-btn>
</template>
</v-data-table>
</v-card-text>
<!-- Add/Edit Dialog -->
<v-dialog v-model="showAddDialog" max-width="800" persistent>
<v-card>
<v-card-title class="pa-4">
{{ editingPayment ? 'ویرایش پرداخت' : 'افزودن پرداخت جدید' }}
</v-card-title>
<v-divider></v-divider>
<v-form ref="form" v-model="valid" @submit.prevent="savePayment">
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="formData.type"
:items="paymentTypes"
label="نوع پرداخت"
:rules="[rules.required]"
required
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="formData.status"
:items="statusOptions"
label="وضعیت"
:rules="[rules.required]"
required
></v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-text-field
class="ltr-input"
:model-value="formatMoney(formData.amount)"
label="مبلغ"
type="text"
inputmode="numeric"
:rules="[rules.required, rules.positiveMoney]"
required
@update:modelValue="onMoneyInput('amount', $event)"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="formData.currency"
:items="currencyOptions"
label="واحد پول"
:rules="[rules.required]"
required
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-text-field
class="ltr-input"
:model-value="formatMoney(formData.amountIRR)"
label="مبلغ (ریال)"
type="text"
inputmode="numeric"
:rules="[rules.positiveMoney]"
@update:modelValue="onMoneyInput('amountIRR', $event)"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<h-date-picker
v-model="formData.paymentDate"
label="تاریخ پرداخت"
:rules="[rules.required]"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.referenceNumber"
label="شماره مرجع"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.bankName"
label="نام بانک"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.accountNumber"
label="شماره حساب"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.recipientName"
label="نام دریافت کننده"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.receiptNumber"
label="شماره رسید"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="formData.description"
label="توضیحات"
rows="2"
></v-textarea>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn @click="closeDialog">لغو</v-btn>
<v-btn
type="submit"
color="primary"
:loading="loading"
:disabled="!valid"
>
{{ editingPayment ? 'ویرایش' : 'افزودن' }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card>
<v-card-title>حذف پرداخت</v-card-title>
<v-card-text>
آیا از حذف این پرداخت اطمینان دارید؟
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="showDeleteDialog = false">لغو</v-btn>
<v-btn color="error" @click="confirmDelete" :loading="deleteLoading">حذف</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import Swal from 'sweetalert2'
import HDatepicker from '@/components/forms/Hdatepicker.vue'
// Props
const props = defineProps({
workflowId: {
type: [String, Number],
required: true
},
payments: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['updated'])
// Data
const loading = ref(false)
const showAddDialog = ref(false)
const showDeleteDialog = ref(false)
const editingPayment = ref(null)
const selectedPayment = ref(null)
const form = ref()
const valid = ref(false)
const saveLoading = ref(false)
const deleteLoading = ref(false)
const formData = ref({
type: '',
amount: '',
currency: 'USD',
amountIRR: '',
paymentDate: '',
referenceNumber: '',
bankName: '',
accountNumber: '',
recipientName: '',
status: 'pending',
description: '',
receiptNumber: ''
})
// Headers
const headers = [
{ title: 'نوع پرداخت', key: 'type', sortable: false },
{ title: 'مبلغ', key: 'amount', sortable: false },
{ title: 'مبلغ (ریال)', key: 'amountIRR', sortable: false },
{ title: 'تاریخ پرداخت', key: 'paymentDate', sortable: false },
{ title: 'دریافت کننده', key: 'recipientName', sortable: false },
{ title: 'وضعیت', key: 'status', sortable: false },
{ title: 'عملیات', key: 'actions', sortable: false, align: 'center' }
]
// Options
const paymentTypes = [
{ title: 'پرداخت به تامین کننده', value: 'supplier' },
{ title: 'پرداخت ترخیصکار', value: 'customs_broker' },
{ title: 'پرداخت حمل و نقل', value: 'shipping' },
{ title: 'پرداخت صرافی', value: 'exchange' },
{ title: 'پرداخت بیمه', value: 'insurance' },
{ title: 'پرداخت انبار موقت', value: 'temporary_storage' },
{ title: 'سایر هزینه‌ها', value: 'other' }
]
const statusOptions = [
{ title: 'در انتظار پرداخت', value: 'pending' },
{ title: 'پرداخت شده', value: 'paid' },
{ title: 'تایید شده', value: 'confirmed' },
{ title: 'لغو شده', value: 'cancelled' }
]
const currencyOptions = [
{ title: 'دلار آمریکا (USD)', value: 'USD' },
{ title: 'یورو (EUR)', value: 'EUR' },
{ title: 'پوند انگلیس (GBP)', value: 'GBP' },
{ title: 'یوان چین (CNY)', value: 'CNY' },
{ title: 'درهم امارات (AED)', value: 'AED' },
{ title: 'ریال (IRR)', value: 'IRR' }
]
// Validation rules
const rules = {
required: (value) => !!value || 'این فیلد الزامی است',
positive: (value) => !value || parseFloat(value) > 0 || 'مقدار باید مثبت باشد',
positiveMoney: (value) => {
const numeric = parseMoneyInput(value)
return numeric > 0 || 'مقدار باید مثبت باشد'
}
}
// Helpers for money formatting/parse and LTR input
const parseMoneyInput = (val) => {
if (val === null || val === undefined) return 0
const cleaned = String(val).replace(/,/g, '').replace(/[^\d.-]/g, '')
const num = Number(cleaned)
return Number.isFinite(num) ? num : 0
}
const onMoneyInput = (field, value) => {
const numeric = parseMoneyInput(value)
formData.value[field] = numeric
}
const formatMoney = (value) => {
const numericValue = Number(value) || 0
return numericValue
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
// Methods
const editPayment = (payment) => {
editingPayment.value = payment
formData.value = { ...payment }
showAddDialog.value = true
}
const deletePayment = (payment) => {
selectedPayment.value = payment
showDeleteDialog.value = true
}
const savePayment = async () => {
if (!valid.value) return
saveLoading.value = true
try {
const url = editingPayment.value
? `/api/import-workflow/${props.workflowId}/payments/${editingPayment.value.id}/update`
: `/api/import-workflow/${props.workflowId}/payments/create`
const method = editingPayment.value ? 'PUT' : 'POST'
const response = await axios({
method,
url,
data: formData.value
})
if (response.data.Success) {
Swal.fire({
title: 'موفق',
text: editingPayment.value ? 'پرداخت با موفقیت ویرایش شد' : 'پرداخت با موفقیت افزوده شد',
icon: 'success'
})
cancelEdit()
emit('updated')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error saving payment:', error)
Swal.fire({
title: 'خطا',
text: 'در ذخیره پرداخت خطایی رخ داد',
icon: 'error'
})
} finally {
saveLoading.value = false
}
}
const confirmDelete = async () => {
deleteLoading.value = true
try {
const response = await axios.delete(`/api/import-workflow/${props.workflowId}/payments/${selectedPayment.value.id}/delete`)
if (response.data.Success) {
Swal.fire({
title: 'موفق',
text: 'پرداخت با موفقیت حذف شد',
icon: 'success'
})
emit('updated')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error deleting payment:', error)
Swal.fire({
title: 'خطا',
text: 'در حذف پرداخت خطایی رخ داد',
icon: 'error'
})
} finally {
deleteLoading.value = false
showDeleteDialog.value = false
}
}
const cancelEdit = () => {
editingPayment.value = null
formData.value = {
type: '',
amount: '',
currency: 'USD',
amountIRR: '',
paymentDate: '',
referenceNumber: '',
bankName: '',
accountNumber: '',
recipientName: '',
status: 'pending',
description: '',
receiptNumber: ''
}
showAddDialog.value = false
if (form.value) {
form.value.reset()
}
}
// Utilities
const getTypeColor = (type) => {
const colors = {
supplier: 'blue',
customs_broker: 'orange',
shipping: 'purple',
exchange: 'teal',
insurance: 'green',
temporary_storage: 'brown',
other: 'grey'
}
return colors[type] || 'grey'
}
const getTypeText = (type) => {
const texts = {
supplier: 'تامین کننده',
customs_broker: 'ترخیصکار',
shipping: 'حمل و نقل',
exchange: 'صرافی',
insurance: 'بیمه',
temporary_storage: 'انبار موقت',
other: 'سایر'
}
return texts[type] || type
}
const getStatusColor = (status) => {
const colors = {
pending: 'orange',
paid: 'blue',
confirmed: 'green',
cancelled: 'red'
}
return colors[status] || 'grey'
}
const getStatusText = (status) => {
const texts = {
pending: 'در انتظار',
paid: 'پرداخت شده',
confirmed: 'تایید شده',
cancelled: 'لغو شده'
}
return texts[status] || status
}
const formatNumber = (number) => {
if (!number) return '0'
return new Intl.NumberFormat('fa-IR').format(number)
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('fa-IR')
}
const closeDialog = () => {
showAddDialog.value = false
cancelEdit()
}
</script>
<style scoped>
.import-workflow-payments {
direction: rtl;
}
:deep(.v-data-table-header th) {
background-color: #f5f5f5 !important;
font-weight: bold !important;
color: #333 !important;
}
:deep(.v-data-table__wrapper table td) {
padding: 12px 16px !important;
border-bottom: 1px solid #e0e0e0 !important;
}
:deep(.v-data-table__wrapper table tr:hover) {
background-color: #f8f9fa !important;
}
:deep(.v-chip) {
font-weight: 500;
}
.custom-header {
background-color: #f5f5f5 !important;
}
</style>

View file

@ -0,0 +1,394 @@
<template>
<div class="import-workflow-shipping">
<v-card-text>
<div class="d-flex justify-space-between align-center mb-4">
<h3>اطلاعات حمل و نقل</h3>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showAddDialog = true"
>
افزودن اطلاعات حمل
</v-btn>
</div>
<v-data-table
:headers="headers"
:items="shipping"
:loading="loading"
density="comfortable"
class="elevation-1"
:header-props="{ class: 'custom-header' }"
no-data-text="اطلاعات حمل و نقلی ثبت نشده است"
>
<template v-slot:item.type="{ item }">
<v-chip
:color="getTypeColor(item.type)"
size="small"
variant="flat"
>
{{ getTypeText(item.type) }}
</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
@click="editShipping(item)"
></v-btn>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="deleteShipping(item)"
></v-btn>
</template>
</v-data-table>
</v-card-text>
<!-- Add/Edit Dialog -->
<v-dialog v-model="showAddDialog" max-width="800" persistent>
<v-card>
<v-card-title class="pa-4">
{{ editingShipping ? 'ویرایش اطلاعات حمل' : 'افزودن اطلاعات حمل جدید' }}
</v-card-title>
<v-divider></v-divider>
<v-form ref="form" v-model="valid" @submit.prevent="saveShipping">
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="formData.type"
:items="shippingTypes"
label="نوع حمل"
:rules="[rules.required]"
required
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.shippingCompany"
label="شرکت حمل و نقل"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.containerNumber"
label="شماره کانتینر"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.billOfLading"
label="شماره بارنامه"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<h-date-picker
v-model="formData.shippingDate"
label="تاریخ حمل"
/>
</v-col>
<v-col cols="12" md="4">
<h-date-picker
v-model="formData.arrivalDate"
label="تاریخ رسیدن"
/>
</v-col>
<v-col cols="12" md="4">
<h-date-picker
v-model="formData.unloadingDate"
label="تاریخ تخلیه"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.originPort"
label="محل مبدا حمل"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.destinationPort"
label="محل مقصد حمل"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.vesselName"
label="نام وسیله/حامل"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.voyageNumber"
label="شماره سفر/پرواز"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="formData.description"
label="توضیحات"
rows="2"
></v-textarea>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn @click="closeDialog">لغو</v-btn>
<v-btn
type="submit"
color="primary"
:loading="saveLoading"
:disabled="!valid"
>
{{ editingShipping ? 'ویرایش' : 'افزودن' }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import Swal from 'sweetalert2'
import HDatepicker from '@/components/forms/Hdatepicker.vue'
// Props
const props = defineProps({
workflowId: {
type: [String, Number],
required: true
},
shipping: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['updated'])
// Data
const loading = ref(false)
const showAddDialog = ref(false)
const editingShipping = ref(null)
const form = ref()
const valid = ref(false)
const saveLoading = ref(false)
const formData = ref({
type: '',
containerNumber: '',
billOfLading: '',
shippingDate: '',
arrivalDate: '',
unloadingDate: '',
shippingCompany: '',
originPort: '',
destinationPort: '',
vesselName: '',
voyageNumber: '',
description: ''
})
// Headers
const headers = [
{ title: 'نوع حمل', key: 'type', sortable: false },
{ title: 'شرکت حمل', key: 'shippingCompany', sortable: false },
{ title: 'شماره کانتینر', key: 'containerNumber', sortable: false },
{ title: 'بندر مبدا', key: 'originPort', sortable: false },
{ title: 'بندر مقصد', key: 'destinationPort', sortable: false },
{ title: 'عملیات', key: 'actions', sortable: false, align: 'center' }
]
// Shipping types
const shippingTypes = [
{ title: 'دریایی', value: 'sea' },
{ title: 'هوایی', value: 'air' },
{ title: 'زمینی', value: 'land' },
{ title: 'ترکیبی', value: 'multimodal' }
]
// Validation rules
const rules = {
required: (value) => !!value || 'این فیلد الزامی است',
maxLength: (value) => !value || value.length <= 500 || 'حداکثر 500 کاراکتر مجاز است',
phone: (value) => !value || /^[\d\-\+\(\)\s]+$/.test(value) || 'شماره تلفن معتبر وارد کنید',
email: (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || 'ایمیل معتبر وارد کنید'
}
// Methods
const editShipping = (shipping) => {
editingShipping.value = shipping
formData.value = { ...shipping }
showAddDialog.value = true
}
const deleteShipping = async (shipping) => {
const result = await Swal.fire({
title: 'حذف اطلاعات حمل',
text: 'آیا از حذف این اطلاعات حمل و نقل اطمینان دارید؟',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'لغو'
})
if (result.isConfirmed) {
try {
const response = await axios.delete(`/api/import-workflow/${props.workflowId}/shipping/${shipping.id}/delete`)
if (response.data.Success) {
Swal.fire('موفق', 'اطلاعات حمل با موفقیت حذف شد', 'success')
emit('updated')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
Swal.fire('خطا', 'در حذف اطلاعات حمل خطایی رخ داد', 'error')
}
}
}
const saveShipping = async () => {
if (!valid.value) return
saveLoading.value = true
try {
const url = editingShipping.value
? `/api/import-workflow/${props.workflowId}/shipping/${editingShipping.value.id}/update`
: `/api/import-workflow/${props.workflowId}/shipping/create`
const method = editingShipping.value ? 'PUT' : 'POST'
const response = await axios({
method,
url,
data: formData.value
})
if (response.data.Success) {
Swal.fire({
title: 'موفق',
text: editingShipping.value ? 'اطلاعات حمل با موفقیت ویرایش شد' : 'اطلاعات حمل با موفقیت افزوده شد',
icon: 'success'
})
closeDialog()
emit('updated')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error saving shipping:', error)
Swal.fire({
title: 'خطا',
text: 'در ذخیره اطلاعات حمل خطایی رخ داد',
icon: 'error'
})
} finally {
saveLoading.value = false
}
}
const closeDialog = () => {
editingShipping.value = null
formData.value = {
type: '',
containerNumber: '',
billOfLading: '',
shippingDate: '',
arrivalDate: '',
unloadingDate: '',
shippingCompany: '',
originPort: '',
destinationPort: '',
vesselName: '',
voyageNumber: '',
description: ''
}
showAddDialog.value = false
if (form.value) {
form.value.reset()
}
}
// Utilities
const getTypeColor = (type) => {
const colors = {
sea: 'blue',
air: 'purple',
land: 'green',
multimodal: 'orange'
}
return colors[type] || 'grey'
}
const getTypeText = (type) => {
const texts = {
sea: 'دریایی',
air: 'هوایی',
land: 'زمینی',
multimodal: 'ترکیبی'
}
return texts[type] || type
}
</script>
<style scoped>
.import-workflow-shipping {
direction: rtl;
}
:deep(.v-data-table-header th) {
background-color: #f5f5f5 !important;
font-weight: bold !important;
color: #333 !important;
}
:deep(.v-data-table__wrapper table td) {
padding: 12px 16px !important;
border-bottom: 1px solid #e0e0e0 !important;
}
:deep(.v-data-table__wrapper table tr:hover) {
background-color: #f8f9fa !important;
}
:deep(.v-chip) {
font-weight: 500;
}
.custom-header {
background-color: #f5f5f5 !important;
}
</style>

View file

@ -0,0 +1,454 @@
<template>
<div class="import-workflow-stages">
<v-card-text>
<div class="d-flex justify-space-between align-center mb-4">
<h3>مراحل واردات</h3>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showAddDialog = true"
>
افزودن مرحله
</v-btn>
</div>
<v-timeline :direction="$vuetify.display.smAndDown ? 'vertical' : 'horizontal'" class="mb-4">
<v-timeline-item
v-for="stage in stages"
:key="stage.id"
:dot-color="getStatusColor(stage.status)"
size="small"
>
<template v-slot:icon>
<v-icon size="small">{{ getStageIcon(stage.stage) }}</v-icon>
</template>
<v-card class="elevation-2">
<v-card-title class="text-h6">
{{ getStageText(stage.stage) }}
</v-card-title>
<v-card-text>
<v-chip
:color="getStatusColor(stage.status)"
size="small"
class="mb-2"
>
{{ getStatusText(stage.status) }}
</v-chip>
<div v-if="stage.startDate">
<small>تاریخ شروع: {{ formatDate(stage.startDate) }}</small>
</div>
<div v-if="stage.endDate">
<small>تاریخ پایان: {{ formatDate(stage.endDate) }}</small>
</div>
</v-card-text>
<v-card-actions>
<v-btn
size="small"
variant="text"
@click="editStage(stage)"
>
ویرایش
</v-btn>
</v-card-actions>
</v-card>
</v-timeline-item>
</v-timeline>
<v-data-table
:headers="headers"
:items="stages"
:loading="loading"
density="comfortable"
class="elevation-1"
:header-props="{ class: 'custom-header' }"
no-data-text="مرحله‌ای ثبت نشده است"
>
<template v-slot:item.stage="{ item }">
<div class="d-flex align-center">
<v-icon class="ml-2">{{ getStageIcon(item.stage) }}</v-icon>
{{ getStageText(item.stage) }}
</div>
</template>
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
variant="flat"
>
{{ getStatusText(item.status) }}
</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
@click="editStage(item)"
></v-btn>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="deleteStage(item)"
></v-btn>
</template>
</v-data-table>
</v-card-text>
<!-- Add/Edit Dialog -->
<v-dialog v-model="showAddDialog" max-width="600" persistent>
<v-card>
<v-card-title class="pa-4">
{{ editingStage ? 'ویرایش مرحله' : 'افزودن مرحله جدید' }}
</v-card-title>
<v-divider></v-divider>
<v-form ref="form" v-model="valid" @submit.prevent="saveStage">
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="formData.stage"
:items="stageTypes"
label="نوع مرحله"
:rules="[rules.required]"
required
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="formData.status"
:items="statusOptions"
label="وضعیت"
:rules="[rules.required]"
required
></v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<h-date-picker
v-model="formData.startDate"
label="تاریخ شروع"
/>
</v-col>
<v-col cols="12" md="6">
<h-date-picker
v-model="formData.endDate"
label="تاریخ پایان"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
v-model="formData.assignedTo"
label="مسئول"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="formData.description"
label="توضیحات"
rows="2"
></v-textarea>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="formData.notes"
label="یادداشت‌ها"
rows="2"
></v-textarea>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn @click="closeDialog">لغو</v-btn>
<v-btn
type="submit"
color="primary"
:loading="saveLoading"
:disabled="!valid"
>
{{ editingStage ? 'ویرایش' : 'افزودن' }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import Swal from 'sweetalert2'
import HDatepicker from '@/components/forms/Hdatepicker.vue'
// Props
const props = defineProps({
workflowId: {
type: [String, Number],
required: true
},
stages: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['updated'])
// Data
const loading = ref(false)
const showAddDialog = ref(false)
const editingStage = ref(null)
const form = ref()
const valid = ref(false)
const saveLoading = ref(false)
const formData = ref({
stage: '',
status: 'pending',
startDate: '',
endDate: '',
assignedTo: '',
description: '',
notes: ''
})
// Headers
const headers = [
{ title: 'مرحله', key: 'stage', sortable: false },
{ title: 'وضعیت', key: 'status', sortable: false },
{ title: 'تاریخ شروع', key: 'startDate', sortable: false },
{ title: 'تاریخ پایان', key: 'endDate', sortable: false },
{ title: 'مسئول', key: 'assignedTo', sortable: false },
{ title: 'عملیات', key: 'actions', sortable: false, align: 'center' }
]
// Stage types
const stageTypes = [
{ title: 'صدور پیش‌فاکتور', value: 'proforma_issue' },
{ title: 'تایید سفارش', value: 'order_confirmation' },
{ title: 'پرداخت', value: 'payment' },
{ title: 'آماده‌سازی کالا', value: 'goods_preparation' },
{ title: 'حمل و نقل', value: 'shipping' },
{ title: 'رسیدن کالا', value: 'arrival' },
{ title: 'ترخیص گمرکی', value: 'customs_clearance' },
{ title: 'انتقال به انبار', value: 'warehouse_transfer' }
]
const statusOptions = [
{ title: 'در انتظار', value: 'pending' },
{ title: 'در حال انجام', value: 'in_progress' },
{ title: 'تکمیل شده', value: 'completed' },
{ title: 'لغو شده', value: 'cancelled' }
]
// Validation rules
const rules = {
required: (value) => !!value || 'این فیلد الزامی است'
}
// Methods
const editStage = (stage) => {
editingStage.value = stage
formData.value = { ...stage }
showAddDialog.value = true
}
const deleteStage = async (stage) => {
const result = await Swal.fire({
title: 'حذف مرحله',
text: `آیا از حذف مرحله "${getStageText(stage.stage)}" اطمینان دارید؟`,
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'حذف',
cancelButtonText: 'لغو'
})
if (result.isConfirmed) {
try {
const response = await axios.delete(`/api/import-workflow/${props.workflowId}/stages/${stage.id}/delete`)
if (response.data.Success) {
Swal.fire('موفق', 'مرحله با موفقیت حذف شد', 'success')
emit('updated')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
Swal.fire('خطا', 'در حذف مرحله خطایی رخ داد', 'error')
}
}
}
const saveStage = async () => {
if (!valid.value) return
saveLoading.value = true
try {
const url = editingStage.value
? `/api/import-workflow/${props.workflowId}/stages/${editingStage.value.id}/update`
: `/api/import-workflow/${props.workflowId}/stages/create`
const method = editingStage.value ? 'PUT' : 'POST'
const response = await axios({
method,
url,
data: formData.value
})
if (response.data.Success) {
Swal.fire({
title: 'موفق',
text: editingStage.value ? 'مرحله با موفقیت ویرایش شد' : 'مرحله با موفقیت افزوده شد',
icon: 'success'
})
cancelEdit()
emit('updated')
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error saving stage:', error)
Swal.fire({
title: 'خطا',
text: 'در ذخیره مرحله خطایی رخ داد',
icon: 'error'
})
} finally {
saveLoading.value = false
}
}
const closeDialog = () => {
showAddDialog.value = false
editingStage.value = null
resetForm()
}
const cancelEdit = () => {
closeDialog()
}
const resetForm = () => {
formData.value = {
stage: '',
status: 'pending',
startDate: '',
endDate: '',
assignedTo: '',
description: '',
notes: ''
}
if (form.value) {
try { form.value.reset() } catch (e) {}
try { form.value.resetValidation && form.value.resetValidation() } catch (e) {}
}
}
// Utilities
const getStageIcon = (stage) => {
const icons = {
proforma_issue: 'mdi-file-document-outline',
order_confirmation: 'mdi-check-circle-outline',
payment: 'mdi-credit-card-outline',
goods_preparation: 'mdi-package-variant',
shipping: 'mdi-truck-delivery-outline',
arrival: 'mdi-map-marker-check',
customs_clearance: 'mdi-gavel',
warehouse_transfer: 'mdi-warehouse'
}
return icons[stage] || 'mdi-circle-outline'
}
const getStageText = (stage) => {
const texts = {
proforma_issue: 'صدور پیش‌فاکتور',
order_confirmation: 'تایید سفارش',
payment: 'پرداخت',
goods_preparation: 'آماده‌سازی کالا',
shipping: 'حمل و نقل',
arrival: 'رسیدن کالا',
customs_clearance: 'ترخیص گمرکی',
warehouse_transfer: 'انتقال به انبار'
}
return texts[stage] || stage
}
const getStatusColor = (status) => {
const colors = {
pending: 'orange',
in_progress: 'blue',
completed: 'green',
cancelled: 'red'
}
return colors[status] || 'grey'
}
const getStatusText = (status) => {
const texts = {
pending: 'در انتظار',
in_progress: 'در حال انجام',
completed: 'تکمیل شده',
cancelled: 'لغو شده'
}
return texts[status] || status
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('fa-IR')
}
</script>
<style scoped>
.import-workflow-stages {
direction: rtl;
}
:deep(.v-data-table-header th) {
background-color: #f5f5f5 !important;
font-weight: bold !important;
color: #333 !important;
}
:deep(.v-data-table__wrapper table td) {
padding: 12px 16px !important;
border-bottom: 1px solid #e0e0e0 !important;
}
:deep(.v-data-table__wrapper table tr:hover) {
background-color: #f8f9fa !important;
}
:deep(.v-chip) {
font-weight: 500;
}
.custom-header {
background-color: #f5f5f5 !important;
}
</style>

View file

@ -137,16 +137,55 @@
<v-window-item value="file">
<div class="mt-4">
<v-card variant="outlined" class="pa-4">
<div class="text-subtitle-1 mb-3">آپلود فایل Excel</div>
<div class="d-flex justify-space-between align-center mb-3">
<div class="text-subtitle-1">آپلود فایل Excel یا CSV</div>
<div class="d-flex gap-2">
<v-btn
color="primary"
variant="outlined"
size="small"
prepend-icon="mdi-download"
@click="downloadSampleFile"
>
دانلود CSV نمونه
</v-btn>
<v-btn
color="success"
variant="outlined"
size="small"
prepend-icon="mdi-download"
@click="downloadExcelSample"
>
دانلود Excel نمونه
</v-btn>
</div>
</div>
<v-alert
type="info"
variant="tonal"
class="mb-3"
>
<div class="text-body-2">
<strong>نکات مهم:</strong>
<ul class="mt-2 mb-0">
<li>فایل باید شامل ستونهای: شماره سریال، کد کالا، توضیحات، شروع گارانتی، پایان گارانتی، وضعیت باشد</li>
<li>شماره سریال و کد کالا فیلدهای الزامی هستند</li>
<li>کد کالا باید دقیقاً مطابق با کد موجود در سیستم باشد</li>
<li>تاریخها باید به فرمت YYYY/MM/DD یا YYYY-MM-DD باشند</li>
<li>وضعیت میتواند: active، inactive، یا expired باشد</li>
</ul>
</div>
</v-alert>
<v-file-input
v-model="uploadedFile"
label="انتخاب فایل"
accept=".xlsx,.xls"
prepend-icon="mdi-file-excel"
accept=".xlsx,.xls,.csv"
prepend-icon="mdi-file-upload"
show-size
counter
@change="handleFileUpload"
@update:model-value="handleFileUpload"
></v-file-input>
<v-alert
@ -213,7 +252,7 @@ const emit = defineEmits<{
const loading = ref(false)
const activeTab = ref('manual')
const uploadedFile = ref(null)
const uploadedFile = ref<File | null>(null)
const filePreview = ref<any[]>([])
const fileErrors = ref<string[]>([])
@ -285,7 +324,7 @@ const tableHeaders = [
const previewHeaders = [
{ title: 'شماره سریال', key: 'serialNumber' },
{ title: 'محصول', key: 'commodity' },
{ title: 'کد کالا', key: 'commodity_code' },
{ title: 'توضیحات', key: 'description' },
{ title: 'شروع گارانتی', key: 'warrantyStartDate' },
{ title: 'پایان گارانتی', key: 'warrantyEndDate' },
@ -336,28 +375,61 @@ const removeManualRow = (index: number) => {
manualSerials.value.splice(index, 1)
}
const handleFileUpload = async (file: File) => {
const handleFileUpload = async (files: File | File[] | null) => {
if (!files) {
filePreview.value = []
fileErrors.value = []
return
}
const file = Array.isArray(files) ? files[0] : files
if (!file) {
filePreview.value = []
fileErrors.value = []
return
}
if (!(file instanceof File)) {
fileErrors.value = ['فایل انتخاب شده معتبر نیست']
return
}
try {
const formData = new FormData()
formData.append('file', file)
const response = await axios.post('/api/plugins/warranty/serials/preview-import', formData, {
const endpoint = '/api/plugins/warranty/serials/preview-import'
const fileName = file.name || ''
const response = await axios.post(endpoint, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
filePreview.value = response.data.preview || []
let previewData = response.data.preview || []
if (previewData.length > 0) {
previewData = previewData.map((item: any) => {
if (item.commodity && !item.commodity_code) {
item.commodity_code = item.commodity
}
if (item.commodity_id && !item.commodity_code) {
item.commodity_code = item.commodity_id
}
return item
})
}
filePreview.value = previewData
fileErrors.value = response.data.errors || []
} catch (error) {
fileErrors.value = ['خطا در خواندن فایل']
console.error(error)
} catch (error: any) {
if (error.response?.data?.error) {
fileErrors.value = [error.response.data.error]
} else {
fileErrors.value = ['خطا در خواندن فایل']
}
}
}
@ -393,7 +465,14 @@ const importData = async () => {
return
}
data = validSerials
data = validSerials.map(s => ({
serialNumber: s.serialNumber,
commodity_code: s.commodity_id,
description: s.description,
warrantyStartDate: s.warrantyStartDate,
warrantyEndDate: s.warrantyEndDate,
status: s.status
}))
} else {
if (!uploadedFile.value) {
await Swal.fire({
@ -407,13 +486,29 @@ const importData = async () => {
const formData = new FormData()
formData.append('file', uploadedFile.value)
const response = await axios.post('/api/plugins/warranty/serials/import-excel', formData, {
const endpoint = '/api/plugins/warranty/serials/import-excel'
const fileName = uploadedFile.value?.name || ''
const response = await axios.post(endpoint, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
data = response.data.serials || []
let fileData = response.data.serials || []
if (fileData.length > 0) {
fileData = fileData.map((item: any) => {
if (item.commodity && !item.commodity_code) {
return {
...item,
commodity_code: item.commodity
}
}
return item
})
}
data = fileData
}
if (data.length === 0) {
@ -438,7 +533,6 @@ const importData = async () => {
icon: 'error',
confirmButtonText: 'قبول'
})
console.error(error)
} finally {
loading.value = false
}
@ -462,6 +556,129 @@ const handleCommoditySelect = (selectedCommodity: any, index: number) => {
}
}
const downloadSampleFile = () => {
const sampleData = [
{
serialNumber: 'SN001',
commodity_code: '1012',
description: 'لپ‌تاپ گیمینگ',
warrantyStartDate: '1403/01/01',
warrantyEndDate: '1406/01/01',
status: 'active'
},
{
serialNumber: 'SN002',
commodity_code: '1012',
description: 'ماوس بی‌سیم',
warrantyStartDate: '1403/02/01',
warrantyEndDate: '1405/02/01',
status: 'active'
},
{
serialNumber: 'SN003',
commodity_code: '1012',
description: 'کیبورد مکانیکال',
warrantyStartDate: '1402/12/01',
warrantyEndDate: '1404/12/01',
status: 'expired'
}
]
const headers = ['شماره سریال', 'کد کالا', 'توضیحات', 'شروع گارانتی', 'پایان گارانتی', 'وضعیت']
const csvContent = [
headers.join(','),
...sampleData.map(row => [
row.serialNumber,
row.commodity_code,
row.description,
row.warrantyStartDate,
row.warrantyEndDate,
row.status
].join(','))
].join('\n')
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', 'نمونه_سریال_گارانتی.csv')
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const downloadExcelSample = () => {
const sampleData = [
{
serialNumber: 'SN001',
commodity_code: '1012',
description: 'لپ‌تاپ گیمینگ',
warrantyStartDate: '1403/01/01',
warrantyEndDate: '1406/01/01',
status: 'active'
},
{
serialNumber: 'SN002',
commodity_code: '1012',
description: 'ماوس بی‌سیم',
warrantyStartDate: '1403/02/01',
warrantyEndDate: '1405/02/01',
status: 'active'
},
{
serialNumber: 'SN003',
commodity_code: '1012',
description: 'کیبورد مکانیکال',
warrantyStartDate: '1402/12/01',
warrantyEndDate: '1404/12/01',
status: 'expired'
}
]
const headers = ['شماره سریال', 'کد کالا', 'توضیحات', 'شروع گارانتی', 'پایان گارانتی', 'وضعیت']
let htmlContent = `
<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">
<head>
<meta charset="UTF-8">
<style>
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: right; }
th { background-color: #f2f2f2; font-weight: bold; }
</style>
</head>
<body>
<table>
<tr>
${headers.map(h => `<th>${h}</th>`).join('')}
</tr>
${sampleData.map(row => `
<tr>
<td>${row.serialNumber}</td>
<td>${row.commodity_code}</td>
<td>${row.description}</td>
<td>${row.warrantyStartDate}</td>
<td>${row.warrantyEndDate}</td>
<td>${row.status}</td>
</tr>
`).join('')}
</table>
</body>
</html>
`
const blob = new Blob([htmlContent], { type: 'application/vnd.ms-excel' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', 'نمونه_سریال_گارانتی.xls')
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const resetData = () => {
manualSerials.value = [{
serialNumber: '',

View file

@ -1,5 +1,5 @@
<template>
<v-dialog v-model="dialog" max-width="600px" persistent>
<v-dialog v-model="dialog" max-width="640px" persistent>
<v-card>
<v-card-title class="d-flex align-center p-3 gap-2">
<v-icon class="mr-3" color="primary">mdi-shield-check</v-icon>
@ -10,104 +10,109 @@
<v-form ref="form" v-model="valid">
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.serialNumber"
label="شماره سریال *"
:rules="[rules.serialNumber]"
required
:disabled="isEdit"
variant="outlined"
density="comfortable"
hide-details="auto"
maxlength="50"
counter
></v-text-field>
<v-text-field v-model="formData.serialNumber" label="شماره سریال *" :rules="[rules.serialNumber]" required
:disabled="isEdit" variant="outlined" density="comfortable" hide-details="auto" maxlength="50" counter>
<template #append>
<v-btn icon small @click="openScanner" :disabled="isEdit" color="primary" variant="text">
<v-icon size="20">mdi-qrcode-scan</v-icon>
</v-btn>
</template>
</v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-autocomplete
v-model="formData.commodity_id"
<Hcommoditysearch
:model-value="formData.commodity_id ?? undefined"
@update:modelValue="(val: number | Record<string, any>) => { formData.commodity_id = typeof val === 'number' ? val : (val as any)?.id ?? null }"
:return-object="false"
label="محصول *"
:items="commodities"
item-title="name"
item-value="id"
:rules="[rules.commodity]"
required
:filter="customFilter"
clearable
return-object
@update:model-value="handleCommoditySelect"
variant="outlined"
density="comfortable"
hide-details="auto"
></v-autocomplete>
</v-col>
<v-col cols="12" md="6">
<h-date-picker
v-model="formData.warrantyStartDate"
label="تاریخ شروع گارانتی"
:rules="[rules.date]"
class="serial-commodity"
/>
</v-col>
<v-col cols="12" md="6">
<h-date-picker
v-model="formData.warrantyEndDate"
label="تاریخ پایان گارانتی"
:rules="[rules.date, (value: any) => rules.endDate(value, formData.warrantyStartDate)]"
/>
<h-date-picker v-model="formData.warrantyStartDate" label="تاریخ شروع گارانتی" :rules="[rules.date]" dense
outlined hide-details="auto" />
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="formData.status"
label="وضعیت"
:items="statusOptions"
item-title="title"
item-value="value"
variant="outlined"
density="comfortable"
hide-details="auto"
></v-select>
<h-date-picker v-model="formData.warrantyEndDate" label="تاریخ پایان گارانتی"
:rules="[(v: any) => rules.endDate(v, formData.warrantyStartDate)]" dense outlined
hide-details="auto" />
</v-col>
<v-col cols="12" md="6">
<v-select v-model="formData.status" label="وضعیت" :items="statusOptions" item-title="title"
item-value="value" variant="outlined" density="comfortable" hide-details="auto" />
</v-col>
<v-col v-if="isEdit" cols="12" md="6">
<v-select v-model="formData.activation" label="وضعیت فعال‌سازی" :items="activationOptions"
item-title="title" item-value="value" variant="outlined" density="comfortable" hide-details="auto" />
</v-col>
<v-col cols="12">
<v-textarea
v-model="formData.description"
label="توضیحات"
rows="3"
auto-grow
variant="outlined"
density="comfortable"
hide-details="auto"
></v-textarea>
<v-textarea v-model="formData.description" label="توضیحات" rows="3" auto-grow variant="outlined"
density="comfortable" hide-details="auto" />
</v-col>
<!-- <v-col cols="12">
<v-textarea
v-model="formData.notes"
label="یادداشت‌ها"
rows="2"
auto-grow
></v-textarea>
</v-col> -->
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-spacer />
<v-btn color="grey" @click="close">انصراف</v-btn>
<v-btn
color="primary"
@click="save"
:loading="loading"
:disabled="!valid"
>
<v-btn color="primary" @click="save" :loading="loading" :disabled="!valid">
{{ isEdit ? 'ویرایش' : 'ذخیره' }}
</v-btn>
</v-card-actions>
</v-card>
<v-dialog v-model="showQrScanner" :max-width="isMobile ? '95vw' : 560" persistent>
<v-card class="qr-card">
<v-card-title class="qr-title">
<v-icon left color="primary">mdi-qrcode-scan</v-icon>
اسکن کد QR/بارکد
</v-card-title>
<v-card-text>
<div class="qr-wrap">
<div id="reader" ref="readerRef" class="qr-reader"></div>
</div>
<div class="qr-status">
<v-alert v-if="scanError" type="error" variant="tonal" density="comfortable">
{{ scanError }}
</v-alert>
<v-progress-circular v-if="loadingScan" indeterminate size="28" class="mt-3" color="primary" />
</div>
</v-card-text>
<v-card-actions class="qr-actions">
<v-btn :disabled="loadingScan" variant="outlined" color="primary" prepend-icon="mdi-camera-switch"
@click="switchCamera">
تغییر دوربین
</v-btn>
<v-spacer />
<v-btn variant="text" @click="closeScanner">انصراف</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="showNotification" :color="notificationColor" :timeout="3000" location="top">
{{ notificationText }}
<template #actions>
<v-btn color="white" text @click="showNotification = false">بستن</v-btn>
</template>
</v-snackbar>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { Html5Qrcode, Html5QrcodeSupportedFormats, Html5QrcodeScannerState } from 'html5-qrcode'
import Hcommoditysearch from '@/components/forms/Hcommoditysearch.vue'
const props = defineProps<{
modelValue: boolean
@ -124,165 +129,331 @@ const emit = defineEmits<{
const loading = ref(false)
const valid = ref(false)
const form = ref()
const commodityModel = ref<any>(null)
const formData = ref({
serialNumber: '',
commodity_id: '',
commodity_id: null as number | null,
description: '',
warrantyStartDate: '',
warrantyEndDate: '',
status: 'active',
status: 'available',
activation: 'deactive',
notes: ''
})
const rules = {
required: (value: any) => !!value || 'این فیلد الزامی است',
serialNumber: (value: any) => {
if (!value) return 'شماره سریال الزامی است'
if (!/^[A-Za-z0-9]+$/.test(value)) {
return 'شماره سریال فقط می‌تواند شامل حروف انگلیسی و اعداد باشد'
}
if (value.length < 3) {
return 'شماره سریال باید حداقل ۳ کاراکتر باشد'
}
if (value.length > 50) {
return 'شماره سریال نمی‌تواند بیشتر از ۵۰ کاراکتر باشد'
}
required: (v: any) => !!v || 'این فیلد الزامی است',
serialNumber: (v: any) => {
if (!v) return 'شماره سریال الزامی است'
if (!/^[A-Za-z0-9\-_.]+$/.test(v)) return 'شماره سریال نامعتبر است'
if (v.length < 3) return 'شماره سریال باید حداقل ۳ کاراکتر باشد'
if (v.length > 50) return 'شماره سریال نمی‌تواند بیشتر از ۵۰ کاراکتر باشد'
return true
},
commodity: (value: any) => {
if (!value) return 'انتخاب محصول الزامی است'
return true
commodity: (v: any) => !!v || 'انتخاب محصول الزامی است',
date: (v: any) => {
if (!v) return true
const d = new Date(v)
return !isNaN(d.getTime()) || 'تاریخ نامعتبر است'
},
date: (value: any) => {
if (!value) return true
const date = new Date(value)
if (isNaN(date.getTime())) {
return 'تاریخ نامعتبر است'
}
return true
},
endDate: (value: any, startDate: any) => {
if (!value || !startDate) return true
const end = new Date(value)
const start = new Date(startDate)
if (end <= start) {
return 'تاریخ پایان باید بعد از تاریخ شروع باشد'
}
return true
endDate: (v: any, s: any) => {
if (!v || !s) return true
const end = new Date(v); const start = new Date(s)
return end > start || 'تاریخ پایان باید بعد از تاریخ شروع باشد'
}
}
const statusOptions = [
{ title: 'فعال', value: 'active' },
{ title: 'غیرفعال', value: 'inactive' },
{ title: 'منقضی شده', value: 'expired' },
// { title: 'استفاده شده', value: 'used' }
{ title: 'آزاد', value: 'available' },
{ title: 'تخصیص یافته', value: 'allocated' },
{ title: 'تأیید شده', value: 'verified' },
{ title: 'متصل', value: 'bound' },
{ title: 'مصرف شده', value: 'consumed' },
{ title: 'باطل', value: 'void' }
]
const activationOptions = [
{ title: 'غیرفعال', value: 'deactive' },
{ title: 'فعال', value: 'active' }
]
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
set: (v) => emit('update:modelValue', v)
})
const isEdit = computed(() => !!props.serial)
const isMobile = computed(() => window.innerWidth <= 768)
const showNotification = ref(false)
const notificationText = ref('')
const notificationColor = ref<'success' | 'error' | 'warning' | 'info'>('success')
const showNotify = (t: string, c: 'success' | 'error' | 'warning' | 'info' = 'success') => {
notificationText.value = t
notificationColor.value = c
showNotification.value = true
}
const showQrScanner = ref(false)
const readerRef = ref<HTMLElement | null>(null)
let qr: Html5Qrcode | null = null
const loadingScan = ref(false)
const scanError = ref('')
const cameras = ref<{ id: string; label: string }[]>([])
const currentCamIndex = ref(0)
const qrboxSize = ref(240)
const computeQrBox = () => {
const el = readerRef.value
if (!el) return 260
const w = Math.max(320, Math.floor(el.clientWidth))
const size = Math.max(220, Math.min(380, Math.floor(w * 0.66)))
return size
}
const openScanner = async () => {
showQrScanner.value = true
await nextTick()
await startScanner()
}
const startScanner = async () => {
try {
loadingScan.value = true
scanError.value = ''
qrboxSize.value = computeQrBox()
const devices = await Html5Qrcode.getCameras()
if (!devices.length) { throw new Error('دوربین یافت نشد') }
cameras.value = devices.map(d => ({ id: d.id, label: d.label }))
if (currentCamIndex.value >= cameras.value.length) currentCamIndex.value = 0
if (qr && (qr.getState?.() === Html5QrcodeScannerState.SCANNING)) await stopScanner()
if (!qr) qr = new Html5Qrcode('reader', {
verbose: false,
formatsToSupport: [
Html5QrcodeSupportedFormats.QR_CODE,
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.CODE_39,
Html5QrcodeSupportedFormats.EAN_13,
Html5QrcodeSupportedFormats.UPC_A,
Html5QrcodeSupportedFormats.DATA_MATRIX
],
experimentalFeatures: { useBarCodeDetectorIfSupported: true }
})
await qr.start(
{ deviceId: { exact: cameras.value[currentCamIndex.value].id } },
{ fps: 12, qrbox: { width: qrboxSize.value, height: qrboxSize.value }, aspectRatio: 1.333 },
(decodedText: string) => {
if (decodedText) {
formData.value.serialNumber = decodedText.trim()
showNotify('کد با موفقیت اسکن شد', 'success')
closeScanner()
}
},
(_err: string) => { }
)
} catch (e: any) {
scanError.value = e?.message || 'خطا در راه‌اندازی دوربین'
} finally {
loadingScan.value = false
}
}
const stopScanner = async () => {
if (!qr) return
try {
const state = qr.getState?.()
if (state === Html5QrcodeScannerState.SCANNING) await qr.stop()
await qr.clear()
} catch { }
}
const closeScanner = async () => {
await stopScanner()
showQrScanner.value = false
}
const switchCamera = async () => {
if (!cameras.value.length) return
currentCamIndex.value = (currentCamIndex.value + 1) % cameras.value.length
await stopScanner()
await nextTick()
await startScanner()
}
const save = async () => {
const isValid = await form.value.validate()
if (!isValid) {
console.log('فرم دارای خطا است')
return
}
if (!formData.value.serialNumber || !formData.value.commodity_id) {
console.log('فیلدهای الزامی پر نشده‌اند')
return
}
const res = await form.value?.validate()
const ok = typeof res === 'object' ? res.valid : !!res
if (!ok) return
if (!formData.value.serialNumber || !formData.value.commodity_id) return
try {
loading.value = true
const data = { ...formData.value }
emit('save', data)
} catch (error) {
console.error(error)
emit('save', { ...formData.value })
} finally {
loading.value = false
}
}
const close = () => {
clearValidationErrors()
closeScanner()
emit('close')
}
const resetForm = () => {
formData.value = {
serialNumber: '',
commodity_id: '',
commodity_id: null,
description: '',
warrantyStartDate: '',
warrantyEndDate: '',
status: 'active',
status: 'available',
activation: 'deactive',
notes: ''
}
if (form.value) {
form.value.resetValidation()
}
}
const clearValidationErrors = () => {
if (form.value) {
form.value.resetValidation()
}
commodityModel.value = null
form.value?.resetValidation()
}
const loadSerialData = () => {
if (props.serial) {
formData.value = {
serialNumber: props.serial.serialNumber || '',
commodity_id: props.serial.commodity?.id || '',
commodity_id: Number(props.serial.commodity?.id) || null,
description: props.serial.description || '',
warrantyStartDate: props.serial.warrantyStartDate || '',
warrantyEndDate: props.serial.warrantyEndDate || '',
status: props.serial.status || 'active',
status: props.serial.status || 'available',
activation: props.serial.activation || 'deactive',
notes: props.serial.notes || ''
}
commodityModel.value = props.commodities.find(c => c.id === formData.value.commodity_id) || null
} else {
resetForm()
}
}
const customFilter = (item: any, queryText: string) => {
const text = item.name.toLowerCase()
const searchText = queryText.toLowerCase()
return text.indexOf(searchText) > -1
const customFilter = (item: any, q: string) => {
const t = String(item?.name || '').toLowerCase()
const s = String(q || '').toLowerCase()
return t.indexOf(s) > -1
}
const handleCommoditySelect = (selectedCommodity: any) => {
if (selectedCommodity && selectedCommodity.id) {
formData.value.commodity_id = selectedCommodity.id
} else {
formData.value.commodity_id = ''
}
const handleCommoditySelect = (c: any) => {
formData.value.commodity_id = c?.id ? Number(c.id) : null
}
watch(() => props.serial, () => {
nextTick(() => {
loadSerialData()
})
}, { immediate: true })
watch(() => props.modelValue, (newVal) => {
if (newVal) {
nextTick(() => {
loadSerialData()
})
}
})
watch(() => props.serial, () => nextTick(loadSerialData), { immediate: true })
watch(() => props.modelValue, v => { if (v) nextTick(loadSerialData) })
onBeforeUnmount(() => { closeScanner() })
</script>
<style scoped>
<style>
/* normalize Hcommoditysearch height with other inputs */
.serial-commodity :deep(.v-field) { min-height: 56px; }
.serial-commodity :deep(.v-field__input) { padding-top: 14px; padding-bottom: 14px; }
#qr-shaded-region {
display: none !important;
}
video {
border-radius: 12px;
padding: 2px;
}
.v-dialog {
direction: rtl;
direction: rtl
}
.qr-card {
max-width: 95vw;
border-radius: 16px
}
.qr-title {
text-align: center;
padding: 14px 16px
}
.qr-wrap {
position: relative;
width: 100%;
max-width: 560px;
margin: 0 auto
}
.qr-reader {
width: 100%;
min-height: 320px;
border-radius: 12px;
border: 1px solid #e5e7eb;
overflow: hidden;
background: #000;
}
/* ویدئو تولیدی کتابخانه */
:deep(#reader video) {
width: 100% !important;
height: auto !important;
display: block !important;
object-fit: cover;
min-height: 240px;
}
/* کانتینر داخلی کتابخانه راست به چپ نشه */
:deep(#reader div) {
direction: ltr
}
/* وضعیت‌ها */
.qr-status {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 12px
}
.qr-actions {
padding: 12px 16px
}
/* ریسپانسیو */
@media (max-width:1024px) {
.qr-reader {
min-height: 300px
}
:deep(#reader video) {
min-height: 220px
}
}
@media (max-width:768px) {
.qr-card {
max-width: 95vw
}
.qr-reader {
min-height: 260px
}
:deep(#reader video) {
min-height: 200px
}
}
@media (max-width:480px) {
.qr-reader {
min-height: 220px
}
:deep(#reader video) {
min-height: 180px
}
}
.v-input.v-input--horizontal.v-input--center-affix.v-input--density-compact.v-theme--light.v-locale--is-rtl.v-input--error.v-text-field.my-0 {
height: 3rem;
}
</style>

View file

@ -1,43 +1,55 @@
<template>
<v-dialog v-model="dialog" max-width="700px">
<v-card>
<v-card class="serial-view">
<v-card-title class="d-flex align-center p-3 gap-2">
<v-icon class="mr-3" color="primary">mdi-shield-check</v-icon>
<span>جزئیات سریال گارانتی</span>
<v-spacer></v-spacer>
<v-chip
:color="getStatusColor(serial?.status)"
size="small"
>
<v-chip :color="getStatusColor(serial?.status)" size="small">
{{ getStatusText(serial?.status) }}
</v-chip>
<v-chip v-if="serial?.expired" color="error" size="small" class="opacity-100">
منقضی شده
</v-chip>
</v-card-title>
<v-card-text v-if="serial">
<v-row>
<v-col cols="12" md="6">
<v-card variant="outlined" class="pa-4">
<div class="text-subtitle-2 text-grey mb-2">اطلاعات سریال</div>
<div class="section-title mb-2">اطلاعات سریال</div>
<v-list>
<v-list-item>
<template v-slot:prepend>
<v-icon color="primary">mdi-barcode</v-icon>
</template>
<v-list-item-title>شماره سریال</v-list-item-title>
<v-list-item-title>سریال گارانتی</v-list-item-title>
<v-list-item-subtitle class="font-weight-bold">
{{ serial.serialNumber }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="serial.commoditySerial">
<template v-slot:prepend>
<v-icon color="primary">mdi-barcode</v-icon>
</template>
<v-list-item-title>سریال کالا</v-list-item-title>
<v-list-item-subtitle class="font-weight-bold">
{{ serial.commoditySerial }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-icon color="primary">mdi-package-variant</v-icon>
</template>
<v-list-item-title>محصول</v-list-item-title>
<v-list-item-subtitle>
<div class="font-weight-bold">{{ serial.commodity?.name }}</div>
<div class="text-caption">کد: {{ serial.commodity?.code }}</div>
</v-list-item-subtitle>
<v-list-item-title>کالا</v-list-item-title>
<router-link :to="`/acc/commodity/mod/${serial.commodity?.code}`">
<v-list-item-subtitle>
<div class="font-weight-bold">{{ serial.commodity?.name }}</div>
<div class="text-caption">کد: {{ serial.commodity?.code }}</div>
</v-list-item-subtitle>
</router-link>
</v-list-item>
<v-list-item>
@ -47,13 +59,24 @@
<v-list-item-title>تاریخ ثبت</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(serial.dateSubmit) }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="serial.allocatedToDocumentCode">
<template v-slot:prepend>
<v-icon color="primary">mdi-file-document</v-icon>
</template>
<v-list-item-title>سند مرتبط</v-list-item-title>
<router-link :to="`/acc/storeroom/ticket/view/${serial.allocatedToDocumentCode}`">
<v-list-item-subtitle class="font-weight-bold text-primary opacity-100">
{{ serial.allocatedToDocumentCode }}
</v-list-item-subtitle>
</router-link>
</v-list-item>
</v-list>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card variant="outlined" class="pa-4">
<div class="text-subtitle-2 text-grey mb-2">اطلاعات گارانتی</div>
<div class="section-title mb-2">اطلاعات گارانتی</div>
<v-list>
<v-list-item>
<template v-slot:prepend>
@ -71,7 +94,9 @@
</template>
<v-list-item-title>پایان گارانتی</v-list-item-title>
<v-list-item-subtitle>
{{ serial.warrantyEndDate ? formatDate(serial.warrantyEndDate) : 'تعیین نشده' }}
<div class="d-inline-flex align-center">
<span>{{ serial.warrantyEndDate ? formatDate(serial.warrantyEndDate) : 'تعیین نشده' }}</span>
</div>
</v-list-item-subtitle>
</v-list-item>
@ -82,20 +107,54 @@
<v-list-item-title>ثبت کننده</v-list-item-title>
<v-list-item-subtitle>{{ serial.submitter?.name }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="serial.buyer">
<template v-slot:prepend>
<v-icon color="info">mdi-account-check</v-icon>
</template>
<v-list-item-title>خریدار</v-list-item-title>
<router-link :to="`/acc/persons/card/view/${serial.buyer?.code}`">
<v-list-item-subtitle class="opacity-100">
<div class="font-weight-bold text-primary">{{ serial.buyer?.nikename || serial.buyer?.name }}
</div>
<div class="text-caption">{{ serial.buyer?.mobile }}</div>
</v-list-item-subtitle>
</router-link>
</v-list-item>
<v-list-item v-if="serial.activation">
<template v-slot:prepend>
<v-icon :color="serial.activation === 'active' ? 'success' : 'warning'">mdi-shield-check</v-icon>
</template>
<v-list-item-title>وضعیت فعالسازی</v-list-item-title>
<v-list-item-subtitle>
<v-chip :color="serial.activation === 'active' ? 'success' : 'warning'" size="small">
{{ serial.activation === 'active' ? 'فعال' : 'غیرفعال' }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="serial.activation && serial.activationAt">
<template v-slot:prepend>
<v-icon color="success">mdi-calendar-check</v-icon>
</template>
<v-list-item-title>تاریخ فعالسازی</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(serial.activationAt) }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
</v-col>
<v-col cols="12" v-if="serial.description">
<v-card variant="outlined" class="pa-4">
<div class="text-subtitle-2 text-grey mb-2">توضیحات</div>
<div class="section-title mb-2">توضیحات</div>
<p class="text-body-1">{{ serial.description }}</p>
</v-card>
</v-col>
<v-col cols="12" v-if="serial.notes">
<v-card variant="outlined" class="pa-4">
<div class="text-subtitle-2 text-grey mb-2">یادداشتها</div>
<div class="section-title mb-2">یادداشتها</div>
<p class="text-body-1">{{ serial.notes }}</p>
</v-card>
</v-col>
@ -135,21 +194,25 @@ const close = () => {
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'success'
case 'inactive': return 'grey'
case 'expired': return 'warning'
case 'used': return 'info'
case 'available': return 'success'
case 'allocated': return 'info'
case 'verified': return 'primary'
case 'bound': return 'warning'
case 'consumed': return 'teal'
case 'void': return 'grey'
default: return 'grey'
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'active': return 'فعال'
case 'inactive': return 'غیرفعال'
case 'expired': return 'منقضی شده'
// case 'used': return 'استفاده شده'
default: return status
case 'available': return 'آزاد'
case 'allocated': return 'تخصیص یافته'
case 'verified': return 'تأیید شده'
case 'bound': return 'متصل'
case 'consumed': return 'مصرف شده'
case 'void': return 'باطل'
default: return 'نامشخص'
}
}
@ -176,8 +239,45 @@ const formatDate = (date: string) => {
}
</script>
<style scoped>
<style>
.v-dialog {
direction: rtl;
}
/* .serial-view .v-card-title {
color: #fff;
} */
.serial-view .v-icon {
opacity: 0.95;
}
.serial-view .v-list-item-title {
font-weight: 600;
color: #263238;
}
.serial-view .v-list-item-subtitle {
color: #111;
font-weight: 600;
}
.serial-view .v-list-item__prepend .v-icon {
color: #0a4798 !important;
}
.serial-view .section-title {
font-size: 0.95rem;
font-weight: 700;
color: #37474f;
}
.serial-view .v-card[variant="outlined"] {
border-color: #e0e0e0 !important;
}
.v-list-item--one-line .v-list-item-subtitle {
-webkit-line-clamp: unset !important;
line-clamp: unset !important;
}
</style>

View file

@ -1060,6 +1060,26 @@ const router = createRouter({
component: () =>
import('../views/acc/inquiry/panel.vue'),
},
{
path: 'plugins/import-workflow/list',
name: 'import_workflow_list',
component: () =>
import('../views/acc/plugins/import-workflow/list.vue'),
meta: {
'title': 'مدیریت واردات کالا',
'login': true,
}
},
{
path: 'plugins/import-workflow/:id',
name: 'import_workflow_detail',
component: () =>
import('../views/acc/plugins/import-workflow/view.vue'),
meta: {
'title': 'جزئیات پرونده واردات',
'login': true,
}
},
],
},
{
@ -1121,6 +1141,23 @@ const router = createRouter({
'title': 'نصب وب اپلیکیشن ',
}
},
// Public routes (no authentication required)
{
path: '/public/:businessId/',
component: () => import('../views/public/PublicLayout.vue'),
children: [
{
path: 'warranty-activation',
name: 'public_warranty_activation',
component: () => import('../views/public/WarrantyActivation.vue'),
meta: {
'title': 'فعال‌سازی گارانتی',
'public': true,
'requiresPlugin': 'warranty'
}
}
]
},
{
path: "/:catchAll(.*)",
name: "not-found",
@ -1138,8 +1175,8 @@ router.beforeEach(async (to, from, next) => {
} else {
document.title = <string>to.meta.title;
}
//check user is login
if (to.meta.login) {
//check user is login (skip for public routes)
if (to.meta.login && !to.meta.public) {
let result = await axios.post('/api/user/check/login');
if (result.status == 200 && result.data.Success == true) {
//check user has role
@ -1163,6 +1200,19 @@ router.beforeEach(async (to, from, next) => {
}
}
if (to.meta.public && to.meta.requiresPlugin && to.params.businessId) {
try {
const response = await axios.get(`/api/public/${to.params.businessId}/status`);
if (!response.data.success) {
next({ 'name': 'not-found', 'params': { catchAll: '404' } });
return;
}
} catch (error: any) {
next({ 'name': 'not-found', 'params': { catchAll: '404' } });
return;
}
}
next();
return
})

View file

@ -0,0 +1,146 @@
/**
* Utility functions for document approval management
*/
/**
* Check if current user can approve a specific document type
* @param {Object} businessSettings - Business settings object
* @param {number} currentUserId - Current user ID
* @param {boolean} isBusinessOwner - Whether current user is business owner
* @param {string} documentType - Type of document (invoice, warehouse, financial)
* @returns {boolean} - Whether user can approve
*/
export function canApproveDocument(businessSettings, currentUserEmail, isBusinessOwner, documentType) {
// If two-step approval is not enabled, anyone can approve
if (!businessSettings.requireTwoStepApproval) {
return true;
}
// Business owner can always approve
if (isBusinessOwner) {
return true;
}
// Check specific approver based on document type
switch (documentType) {
case 'invoice':
return businessSettings.invoiceApprover === currentUserEmail;
case 'warehouse':
return businessSettings.warehouseApprover === currentUserEmail;
case 'financial':
return businessSettings.financialApprover === currentUserEmail;
default:
return false;
}
}
/**
* Get document type from document object
* @param {Object} document - Document object
* @returns {string} - Document type
*/
export function getDocumentType(document) {
if (document.type) {
return document.type;
}
// Fallback based on document properties
if (document.invoiceNumber) {
return 'invoice';
}
if (document.warehouseNumber) {
return 'warehouse';
}
if (document.paymentType || document.receiptType) {
return 'financial';
}
return 'unknown';
}
/**
* Check if document needs approval
* @param {Object} businessSettings - Business settings object
* @param {Object} document - Document object
* @returns {boolean} - Whether document needs approval
*/
export function needsApproval(businessSettings, document) {
if (!businessSettings.requireTwoStepApproval) {
return false;
}
// Check if document is pending approval
return document.status === 'pending_approval' ||
document.approvalStatus === 'pending' ||
document.approved === false;
}
/**
* Get approval button visibility
* @param {Object} businessSettings - Business settings object
* @param {Object} document - Document object
* @param {number} currentUserId - Current user ID
* @param {boolean} isBusinessOwner - Whether current user is business owner
* @returns {boolean} - Whether approval button should be visible
*/
export function shouldShowApprovalButton(businessSettings, document, currentUserEmail, isBusinessOwner) {
// Check if document needs approval
if (!needsApproval(businessSettings, document)) {
return false;
}
// Check if user can approve
const documentType = getDocumentType(document);
return canApproveDocument(businessSettings, currentUserEmail, isBusinessOwner, documentType);
}
/**
* Get approval button text
* @param {Object} document - Document object
* @returns {string} - Button text
*/
export function getApprovalButtonText(document) {
if (document.status === 'pending_approval' || document.approvalStatus === 'pending') {
return 'تایید سند';
}
if (document.approved === false) {
return 'تایید مجدد';
}
return 'تایید';
}
/**
* Get approval status text
* @param {Object} document - Document object
* @returns {string} - Status text
*/
export function getApprovalStatusText(document) {
if (document.status === 'pending_approval' || document.approvalStatus === 'pending') {
return 'در انتظار تایید';
}
if (document.approved === true) {
return 'تایید شده';
}
if (document.approved === false) {
return 'رد شده';
}
return 'نامشخص';
}
/**
* Get approval status color
* @param {Object} document - Document object
* @returns {string} - Status color
*/
export function getApprovalStatusColor(document) {
if (document.status === 'pending_approval' || document.approvalStatus === 'pending') {
return 'warning';
}
if (document.approved === true) {
return 'success';
}
if (document.approved === false) {
return 'error';
}
return 'grey';
}

View file

@ -198,6 +198,7 @@ export default {
{ path: '/acc/plugins/tax/invoices/list', key: 'L', label: this.$t('drawer.tax_invoices'), ctrl: true, shift: true, permission: () => this.permissions.settings && this.isPluginActive('taxsettings') },
{ path: '/acc/plugins/tax/settings', key: 'T', label: this.$t('drawer.tax_settings'), ctrl: true, shift: true, permission: () => this.permissions.settings && this.isPluginActive('taxsettings') },
{ path: '/acc/plugins/custominvoice/templates', key: 'I', label: 'قالب‌های فاکتور', ctrl: true, shift: true, permission: () => this.permissions.settings && this.isPluginActive('custominvoice') },
{ path: '/acc/plugins/import-workflow', key: 'I', label: 'مدیریت واردات کالا', ctrl: true, shift: true, permission: () => this.permissions.importWorkflow },
];
},
restorePermissions(shortcuts) {
@ -788,6 +789,26 @@ export default {
</v-list-item>
</v-list-group>
<v-list-subheader color="primary">{{ $t('drawer.services') }}</v-list-subheader>
<v-list-group v-show="isPluginActive('import-workflow') && permissions.importWorkflow">
<template v-slot:activator="{ props }">
<v-list-item class="text-dark" v-bind="props" title="مدیریت واردات کالا">
<template v-slot:prepend><v-icon icon="mdi-import" color="primary"></v-icon></template>
</v-list-item>
</template>
<v-list-item v-if="permissions.importWorkflow" to="/acc/plugins/import-workflow/list">
<v-list-item-title>
لیست پروندههای واردات
<span v-if="isCtrlShiftPressed" class="shortcut-key">{{ getShortcutKey('/acc/plugins/import-workflow/list') }}</span>
</v-list-item-title>
<template v-slot:append>
<v-tooltip text="پرونده واردات جدید" location="end">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-plus-box" variant="plain" @click="$router.push('/acc/plugins/import-workflow/list')" />
</template>
</v-tooltip>
</template>
</v-list-item>
</v-list-group>
<v-list-group v-show="permissions.plugRepservice && isPluginActive('repservice')">
<template v-slot:activator="{ props }">
<v-list-item class="text-dark" v-bind="props" :title="$t('drawer.repservice')">

View file

@ -11,6 +11,9 @@
<v-btn color="primary" size="small" @click="dialog = true" :loading="loading" prepend-icon="mdi-bank">
{{ $t('dialog.banks_accounts') }}
</v-btn>
<v-btn color="warning" size="small" @click="editPersonDialog = true" :loading="loading" prepend-icon="mdi-account-edit" class="ml-2">
ویرایش شخص
</v-btn>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="" color="red">
@ -91,6 +94,81 @@
</v-card>
</v-dialog>
<!-- دیالوگ ویرایش شخص -->
<v-dialog v-model="editPersonDialog" max-width="600" persistent>
<v-card>
<v-toolbar color="primary-dark" dense flat>
<v-toolbar-title class="text-white">ویرایش شخص</v-toolbar-title>
<v-spacer />
<v-btn icon @click="editPersonDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar>
<v-card-text class="pa-4">
<v-form ref="editPersonForm" v-model="editPersonFormValid">
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="editPersonData.nikename"
label="نام مستعار"
dense
required
:rules="[v => !!v || 'نام مستعار الزامی است']"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="editPersonData.name"
label="نام کامل"
dense
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="editPersonData.mobile"
label="موبایل"
dense
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="editPersonData.tel"
label="تلفن"
dense
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="editPersonData.address"
label="آدرس"
dense
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="editPersonData.des"
label="توضیحات"
dense
/>
</v-col>
<v-col cols="12">
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="secondary" text @click="editPersonDialog = false">
انصراف
</v-btn>
<v-btn color="primary" @click="savePersonChanges" :loading="saveLoading" :disabled="!editPersonFormValid">
ذخیره
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- محتوای اصلی -->
<v-container fluid class="pa-4">
<v-row dense>
@ -179,6 +257,7 @@
$filters.formatNumber(selectedPerson.bd) || '-' }}</span></div>
<div class="text-subtitle-2">{{ $t('pages.person_card.accounting_balance') }}: <span class="text-primary">{{
$filters.formatNumber(selectedPerson.balance) || '-' }}</span></div>
<v-divider class="my-2" />
</v-card-text>
</v-card>
</v-col>
@ -245,6 +324,18 @@ export default {
items: [],
loading: ref(false),
dialog: false,
editPersonDialog: false,
editPersonFormValid: false,
saveLoading: false,
editPersonData: {
nikename: '',
name: '',
mobile: '',
tel: '',
address: '',
des: '',
},
debounceTimeout: null, // برای مدیریت debounce
headers: [
{ title: this.$t('dialog.operation'), key: "operation", align: "center", sortable: false },
@ -318,6 +409,16 @@ export default {
try {
const personResponse = await axios.post('/api/person/info/' + id);
this.selectedPerson = personResponse.data;
// پر کردن فرم ویرایش با اطلاعات فعلی شخص
this.editPersonData = {
nikename: this.selectedPerson.nikename || '',
name: this.selectedPerson.name || '',
mobile: this.selectedPerson.mobile || '',
tel: this.selectedPerson.tel || '',
address: this.selectedPerson.address || '',
des: this.selectedPerson.des || '',
};
const rowsResponse = await axios.post('/api/accounting/rows/search', { type: 'person', id });
this.items = rowsResponse.data;
@ -329,6 +430,29 @@ export default {
this.loading = false;
}
},
async savePersonChanges() {
if (!this.selectedPerson || !this.selectedPerson.code) {
this.snackbar = { show: true, text: 'شخص انتخاب نشده است', color: 'error' };
return;
}
this.saveLoading = true;
try {
const response = await axios.post('/api/person/mod/' + this.selectedPerson.code, this.editPersonData);
if (response.data.Success) {
this.snackbar = { show: true, text: 'اطلاعات شخص با موفقیت بروزرسانی شد', color: 'success' };
this.editPersonDialog = false;
// بروزرسانی اطلاعات شخص
await this.loadPerson(this.selectedPerson.code);
} else {
this.snackbar = { show: true, text: 'خطا در بروزرسانی اطلاعات شخص', color: 'error' };
}
} catch (error) {
console.error('Save person error:', error);
this.snackbar = { show: true, text: 'خطا در بروزرسانی اطلاعات شخص', color: 'error' };
} finally {
this.saveLoading = false;
}
},
async excellOutput(allItems = true) {
if (!allItems && this.itemsSelected.length === 0) {
Swal.fire({ text: this.$t('pages.person_card.no_items_selected'), icon: 'info', confirmButtonText: this.$t('dialog.confirm') });
@ -409,6 +533,16 @@ export default {
return labels[type] || type;
},
},
computed: {
snackbar: {
get() {
return this.$store.state.snackbar;
},
set(value) {
this.$store.commit('setSnackbar', value);
}
}
}
};
</script>

View file

@ -65,6 +65,7 @@
<v-text-field v-model="person.des" :label="$t('pages.person.description')" dense
prepend-inner-icon="mdi-text" hide-details />
</v-col>
</v-row>
</v-card-text>
</v-card>
@ -287,7 +288,8 @@ export default {
types: [],
accounts: [],
prelabel: ref(null),
speedAccess: false
speedAccess: false,
},
snackbar: {
show: false,
@ -346,6 +348,8 @@ export default {
if (id) {
axios.post('/api/person/info/' + id).then((response) => {
console.log('Loaded person data:', response.data);
this.person = response.data;
this.loading = false;
}).catch((error) => {
@ -404,6 +408,8 @@ export default {
if (canSubmit) {
this.loading = true;
try {
console.log('Saving person data:', this.person);
const response = await axios.post('/api/person/mod/' + this.person.code, this.person);
this.loading = false;
if (response.data && response.data.result === 2) {

View file

@ -35,6 +35,13 @@
</v-list-item>
</v-list>
</v-menu>
<v-tooltip text="تایید پرداخت‌های انتخابی" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon color="success" @click="approveSelectedPayments">
<v-icon>mdi-check-decagram</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon color="success">
@ -385,6 +392,28 @@ const updateSelectedSum = () => {
}
};
const approveSelectedPayments = async () => {
if (selectedItems.value.length === 0) {
Swal.fire({ text: 'هیچ آیتمی انتخاب نشده است.', icon: 'warning', confirmButtonText: 'قبول' });
return;
}
const res = await Swal.fire({ title: 'تایید پرداخت‌ها', text: 'پرداخت‌های انتخابی تایید خواهند شد.', icon: 'question', showCancelButton: true, confirmButtonText: 'بله', cancelButtonText: 'خیر' });
if (!res.isConfirmed) return;
loading.value = true;
try {
for (const it of selectedItems.value) {
await axios.post(`/api/sell/payment/approve/${it.code}`);
}
Swal.fire({ text: 'پرداخت‌ها تایید شدند.', icon: 'success', confirmButtonText: 'قبول' });
selectedItems.value = [];
await loadData();
} catch (e) {
Swal.fire({ text: 'خطا در تایید پرداخت‌ها', icon: 'error', confirmButtonText: 'قبول' });
} finally {
loading.value = false;
}
};
const print = async (allItems = true) => {
if (!allItems && selectedItems.value.length === 0) {
Swal.fire({

View file

@ -0,0 +1,681 @@
<template>
<div class="import-workflow-list">
<v-container fluid>
<!-- Stats Cards -->
<!-- <v-row>
<v-col cols="6" sm="6" md="3">
<div class="stats-card total-card">
<div class="stats-icon">
<v-icon size="24" color="white" class="d-sm-none">mdi-import</v-icon>
<v-icon size="32" color="white" class="d-none d-sm-block">mdi-import</v-icon>
</div>
<div class="stats-content">
<div class="stats-number">
<v-progress-circular
v-if="statsLoading"
indeterminate
size="20"
color="white"
class="me-2"
></v-progress-circular>
{{ stats.totalWorkflows || 0 }}
</div>
<div class="stats-label">کل پروندهها</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="6" md="3">
<div :class="getCardClasses('draft')" @click="filterByStatus('draft')" role="button" tabindex="0">
<div class="stats-icon">
<v-icon size="24" color="white" class="d-sm-none">mdi-file-document-outline</v-icon>
<v-icon size="32" color="white" class="d-none d-sm-block">mdi-file-document-outline</v-icon>
</div>
<div class="stats-content">
<div class="stats-number">
<v-progress-circular
v-if="statsLoading"
indeterminate
size="20"
color="white"
class="me-2"
></v-progress-circular>
{{ stats.draftWorkflows || 0 }}
</div>
<div class="stats-label">پیشنویس</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="6" md="3">
<div :class="getCardClasses('processing')" @click="filterByStatus('processing')" role="button" tabindex="0">
<div class="stats-icon">
<v-icon size="24" color="white" class="d-sm-none">mdi-progress-clock</v-icon>
<v-icon size="32" color="white" class="d-none d-sm-block">mdi-progress-clock</v-icon>
</div>
<div class="stats-content">
<div class="stats-number">
<v-progress-circular
v-if="statsLoading"
indeterminate
size="20"
color="white"
class="me-2"
></v-progress-circular>
{{ stats.processingWorkflows || 0 }}
</div>
<div class="stats-label">در حال پردازش</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="6" md="3">
<div :class="getCardClasses('completed')" @click="filterByStatus('completed')" role="button" tabindex="0">
<div class="stats-icon">
<v-icon size="24" color="white" class="d-sm-none">mdi-check-circle</v-icon>
<v-icon size="32" color="white" class="d-none d-sm-block">mdi-check-circle</v-icon>
</div>
<div class="stats-content">
<div class="stats-number">
<v-progress-circular
v-if="statsLoading"
indeterminate
size="20"
color="white"
class="me-2"
></v-progress-circular>
{{ stats.completedWorkflows || 0 }}
</div>
<div class="stats-label">تکمیل شده</div>
</div>
</div>
</v-col>
</v-row> -->
<!-- Data Table -->
<v-row>
<v-col cols="12">
<v-card>
<v-data-table
:headers="headers"
:items="workflows"
:loading="loading"
density="comfortable"
class="elevation-1"
:header-props="{ class: 'custom-header' }"
hover
>
<template v-slot:top>
<!-- موبایل -->
<div class="d-block d-md-none pa-4">
<div class="d-flex gap-2 flex-column mb-3">
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showCreateDialog = true"
size="small"
block
>
پرونده واردات جدید
</v-btn>
</div>
<v-text-field
v-model="filters.search"
label="جستجو"
prepend-icon="mdi-magnify"
clearable
density="compact"
variant="outlined"
hide-details
class="mb-3"
@update:model-value="loadWorkflows"
/>
<v-select
v-model="filters.status"
label="وضعیت"
:items="statusOptions"
clearable
density="compact"
variant="outlined"
hide-details
class="mb-3"
@update:model-value="loadWorkflows"
/>
</div>
<!-- دسکتاپ -->
<div class="d-none d-md-block">
<v-toolbar flat style="height: 70px !important; padding: 10px !important;">
<v-text-field
v-model="filters.search"
label="جستجو"
prepend-icon="mdi-magnify"
clearable
density="compact"
variant="outlined"
hide-details
style="max-width: 250px;"
@update:model-value="loadWorkflows"
class="ml-2"
/>
<v-select
v-model="filters.status"
label="وضعیت"
:items="statusOptions"
clearable
density="compact"
variant="outlined"
hide-details
style="max-width: 200px;"
@update:model-value="loadWorkflows"
class="ml-2"
/>
<v-spacer></v-spacer>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showCreateDialog = true"
>
پرونده واردات جدید
</v-btn>
</v-toolbar>
</div>
</template>
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
>
{{ getStatusText(item.status) }}
</v-chip>
</template>
<template v-slot:item.totalAmount="{ item }">
<div>
{{ formatNumber(item.totalAmount) }}
<small class="text-medium-emphasis">{{ item.currency }}</small>
</div>
</template>
<template v-slot:item.dateSubmit="{ item }">
{{ formatDate(item.dateSubmit) }}
</template>
<template v-slot:item.actions="{ item }">
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-menu"
variant="text"
size="small"
color="error"
></v-btn>
</template>
<v-list>
<v-list-item @click="viewWorkflow(item)">
<template v-slot:prepend>
<v-icon color="info">mdi-eye</v-icon>
</template>
<v-list-item-title>مشاهده پرونده</v-list-item-title>
</v-list-item>
<v-list-item @click="editWorkflow(item)">
<template v-slot:prepend>
<v-icon color="warning">mdi-pencil</v-icon>
</template>
<v-list-item-title>ویرایش پرونده</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteWorkflow(item)">
<template v-slot:prepend>
<v-icon color="error">mdi-delete</v-icon>
</template>
<v-list-item-title>حذف پرونده</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
</v-container>
<!-- Create Dialog -->
<ImportWorkflowCreateDialog
v-model="showCreateDialog"
@created="onWorkflowCreated"
/>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card>
<v-card-title>حذف پرونده واردات</v-card-title>
<v-card-text>
آیا از حذف پرونده واردات "{{ selectedWorkflow?.title }}" اطمینان دارید؟
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="showDeleteDialog = false">لغو</v-btn>
<v-btn color="error" @click="confirmDelete" :loading="deleteLoading">حذف</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar for notifications -->
<v-snackbar
v-model="showSnackbar"
:color="snackbarColor"
:timeout="3000"
location="bottom"
class="rounded-lg"
elevation="2"
>
<div class="d-flex align-center">
<v-icon :color="snackbarColor" class="me-2">
{{ snackbarColor === 'success' ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
{{ snackbarText }}
</div>
<template v-slot:actions>
<v-btn icon variant="text" @click="showSnackbar = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import Swal from 'sweetalert2'
import ImportWorkflowCreateDialog from '../../../../components/plugins/import-workflow/ImportWorkflowCreateDialog.vue'
const router = useRouter()
// Data
const workflows = ref([])
const loading = ref(false)
const statsLoading = ref(false)
const showCreateDialog = ref(false)
const showDeleteDialog = ref(false)
const selectedWorkflow = ref(null)
const deleteLoading = ref(false)
// Stats
const stats = ref({
totalWorkflows: 0,
draftWorkflows: 0,
processingWorkflows: 0,
completedWorkflows: 0
})
// Notifications
const showSnackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
const showNotification = (text, color = 'success') => {
snackbarText.value = text
snackbarColor.value = color
showSnackbar.value = true
}
// Filters
const filters = ref({
status: '',
search: ''
})
// Pagination
const pagination = ref({
page: 1,
limit: 20,
total: 0
})
// Headers
const headers = [
{ title: 'عملیات', key: 'actions', sortable: false },
{ title: 'کد', key: 'code', sortable: true },
{ title: 'عنوان', key: 'title', sortable: true },
{ title: 'تامین کننده', key: 'supplierName', sortable: true },
{ title: 'مبلغ کل', key: 'totalAmount', sortable: true },
{ title: 'وضعیت', key: 'status', sortable: true },
{ title: 'تاریخ ثبت', key: 'dateSubmit', sortable: true },
{ title: 'ثبت کننده', key: 'submitter', sortable: true }
]
// Status options
const statusOptions = [
{ title: 'پیش‌نویس', value: 'draft' },
{ title: 'در حال پردازش', value: 'processing' },
{ title: 'ارسال شده', value: 'shipped' },
{ title: 'رسیده', value: 'arrived' },
{ title: 'ترخیص شده', value: 'cleared' },
{ title: 'تکمیل شده', value: 'completed' },
{ title: 'لغو شده', value: 'cancelled' }
]
// Methods
const loadWorkflows = async () => {
loading.value = true
try {
const params = {
page: pagination.value.page,
limit: pagination.value.limit
}
if (filters.value.status) {
params.status = filters.value.status
}
if (filters.value.search) {
params.search = filters.value.search
}
const response = await axios.get('/api/import-workflow/list', { params })
if (response.data.Success) {
workflows.value = response.data.Result.data
pagination.value.total = response.data.Result.total
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error loading workflows:', error)
showNotification('در بارگذاری لیست پرونده‌های واردات خطایی رخ داد', 'error')
} finally {
loading.value = false
}
}
const loadStats = async () => {
try {
statsLoading.value = true
const response = await axios.get('/api/import-workflow/stats')
if (response.data.Success) {
stats.value = response.data.Result
} else {
// Calculate stats from current data if API not available
const totalWorkflows = workflows.value.length
const draftWorkflows = workflows.value.filter(w => w.status === 'draft').length
const processingWorkflows = workflows.value.filter(w => w.status === 'processing').length
const completedWorkflows = workflows.value.filter(w => w.status === 'completed').length
stats.value = {
totalWorkflows,
draftWorkflows,
processingWorkflows,
completedWorkflows
}
}
} catch (error) {
console.error('خطا در بارگذاری آمار:', error)
// Calculate stats from current data
const totalWorkflows = workflows.value.length
const draftWorkflows = workflows.value.filter(w => w.status === 'draft').length
const processingWorkflows = workflows.value.filter(w => w.status === 'processing').length
const completedWorkflows = workflows.value.filter(w => w.status === 'completed').length
stats.value = {
totalWorkflows,
draftWorkflows,
processingWorkflows,
completedWorkflows
}
} finally {
statsLoading.value = false
}
}
const updatePagination = (options) => {
pagination.value.page = options.page
pagination.value.limit = options.itemsPerPage
loadWorkflows()
}
const filterByStatus = (status) => {
filters.value.status = filters.value.status === status ? '' : status
loadWorkflows()
}
const viewWorkflow = (workflow) => {
router.push(`/acc/plugins/import-workflow/${workflow.id}`)
}
const editWorkflow = (workflow) => {
router.push(`/acc/plugins/import-workflow/${workflow.id}`)
}
const deleteWorkflow = (workflow) => {
selectedWorkflow.value = workflow
showDeleteDialog.value = true
}
const confirmDelete = async () => {
deleteLoading.value = true
try {
const response = await axios.delete(`/api/import-workflow/${selectedWorkflow.value.id}/delete`)
if (response.data.Success) {
showNotification('پرونده واردات با موفقیت حذف شد')
loadWorkflows()
// loadStats()
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error deleting workflow:', error)
showNotification('در حذف پرونده واردات خطایی رخ داد', 'error')
} finally {
deleteLoading.value = false
showDeleteDialog.value = false
}
}
const onWorkflowCreated = () => {
showCreateDialog.value = false
loadWorkflows()
// loadStats()
}
// Utilities
const getStatusColor = (status) => {
const colors = {
draft: 'grey',
processing: 'blue',
shipped: 'orange',
arrived: 'purple',
cleared: 'teal',
completed: 'green',
cancelled: 'red'
}
return colors[status] || 'grey'
}
const getStatusText = (status) => {
const texts = {
draft: 'پیش‌نویس',
processing: 'در حال پردازش',
shipped: 'ارسال شده',
arrived: 'رسیده',
cleared: 'ترخیص شده',
completed: 'تکمیل شده',
cancelled: 'لغو شده'
}
return texts[status] || status
}
const getCardClasses = (status) => {
const baseClasses = 'stats-card'
const statusClasses = {
'draft': 'draft-card',
'processing': 'processing-card',
'completed': 'completed-card'
}
const classes = [baseClasses, statusClasses[status]]
if (filters.value.status === status) {
classes.push('active-filter')
}
return classes.join(' ')
}
const formatNumber = (number) => {
if (!number) return '0'
return new Intl.NumberFormat('fa-IR').format(number)
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('fa-IR')
}
// Lifecycle
onMounted(async () => {
await Promise.all([
// loadStats(),
loadWorkflows()
])
})
</script>
<style scoped>
.import-workflow-list {
padding: 20px;
}
.v-data-table {
direction: rtl;
}
:deep(.v-data-table-header th) {
background-color: #f5f5f5 !important;
font-weight: bold !important;
color: #333 !important;
}
:deep(.v-data-table__wrapper table td) {
padding: 12px 16px !important;
border-bottom: 1px solid #e0e0e0 !important;
}
:deep(.v-data-table__wrapper table tr:hover) {
background-color: #f8f9fa !important;
}
:deep(.v-chip) {
font-weight: 500;
}
.custom-header {
background-color: #f5f5f5 !important;
}
.stats-card {
position: relative;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 24px;
color: white;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
cursor: pointer;
min-height: 120px;
display: flex;
align-items: center;
gap: 16px;
user-select: none;
}
.stats-card:focus {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 2px;
}
.stats-card.active-filter {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2);
border: 2px solid rgba(255, 255, 255, 0.8);
}
.stats-card.active-filter::before {
background: linear-gradient(45deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 100%);
}
.stats-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%);
border-radius: 16px;
z-index: 1;
}
.stats-card:hover {
transform: translateY(-8px) scale(1.01);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.stats-icon {
position: relative;
z-index: 2;
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 12px;
backdrop-filter: blur(10px);
}
.stats-content {
position: relative;
z-index: 2;
flex: 1;
}
.stats-number {
font-size: 2.5rem;
font-weight: 700;
line-height: 1;
margin-bottom: 4px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.stats-label {
font-size: 0.9rem;
font-weight: 500;
opacity: 0.9;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
/* Card Variants - Professional Colors */
.total-card {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
}
.draft-card {
background: linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%);
}
.processing-card {
background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
}
.completed-card {
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
}
@media (max-width: 768px) {
.stats-card {
flex-direction: column;
align-items: center;
text-align: center;
}
}
</style>

View file

@ -0,0 +1,821 @@
<template>
<div class="import-workflow-detail">
<v-container fluid>
<!-- Header -->
<v-row>
<v-col cols="12">
<v-card class="mb-4">
<v-card-title class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-btn
icon="mdi-arrow-right"
variant="text"
@click="$router.back()"
class="ml-2"
></v-btn>
<v-icon class="ml-2" color="primary">mdi-import</v-icon>
<span>{{ workflow?.title || 'جزئیات پرونده واردات' }}</span>
</div>
<div class="d-flex align-center">
<v-chip
:color="getStatusColor(workflow?.status)"
class="ml-2"
>
{{ getStatusText(workflow?.status) }}
</v-chip>
<v-btn
color="primary"
variant="outlined"
prepend-icon="mdi-pencil"
@click="editMode = !editMode"
>
{{ editMode ? 'لغو ویرایش' : 'ویرایش' }}
</v-btn>
<!-- <v-btn
class="mr-2"
color="success"
prepend-icon="mdi-warehouse"
@click="openCreateTicketDialog"
>
ایجاد حواله ورود از پرونده
</v-btn> -->
</div>
</v-card-title>
</v-card>
</v-col>
</v-row>
<v-row v-if="loading">
<v-col cols="12" class="text-center">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</v-col>
</v-row>
<template v-else-if="workflow">
<!-- Basic Info -->
<v-row>
<v-col cols="12" md="8">
<v-card class="mb-4">
<v-card-title>اطلاعات کلی</v-card-title>
<v-card-text>
<v-form v-if="editMode" ref="form" v-model="valid" validate-on="input">
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="editData.title"
label="عنوان پرونده"
:rules="[rules.required, rules.minLength]"
counter="100"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="editData.supplierName"
label="نام تامین کننده"
:rules="[rules.required, rules.minLength]"
counter="100"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="editData.supplierCountry"
label="کشور تامین کننده"
:rules="[rules.maxLength]"
counter="50"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="editData.supplierPhone"
label="تلفن تامین کننده"
:rules="[rules.phone]"
counter="20"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
v-model="editData.supplierEmail"
label="ایمیل تامین کننده"
:rules="[rules.email]"
counter="100"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="editData.supplierAddress"
label="آدرس تامین کننده"
rows="2"
:rules="[rules.maxLength]"
counter="500"
></v-textarea>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<v-text-field
class="ltr-input"
:model-value="formatMoney(editData.totalAmount)"
label="مبلغ کل (ارزی)"
type="text"
inputmode="numeric"
:rules="[rules.positiveMoney]"
@update:modelValue="onMoneyInput('totalAmount', $event)"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="editData.currency"
:items="currencyOptions"
label="واحد پول"
:rules="[rules.required]"
></v-select>
</v-col>
<v-col cols="12" md="4">
<v-text-field
class="ltr-input"
:model-value="formatMoney(editData.exchangeRate)"
label="نرخ تبدیل (ریال)"
type="text"
inputmode="numeric"
:rules="[rules.exchangeRateRule]"
@update:modelValue="onMoneyInput('exchangeRate', $event)"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
:model-value="formatMoney(editData.totalAmountIRR)"
label="مبلغ کل (ریال)"
readonly
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-textarea
v-model="editData.description"
label="توضیحات"
rows="3"
></v-textarea>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-btn
color="primary"
@click="saveChanges"
:loading="saveLoading"
:disabled="!isFormValidForSave"
>
ذخیره تغییرات
</v-btn>
</v-col>
</v-row>
</v-form>
<div v-else>
<v-row>
<v-col cols="12" md="6">
<div class="mb-3">
<strong>نام تامین کننده:</strong>
<div>{{ workflow.supplierName || '-' }}</div>
</div>
</v-col>
<v-col cols="12" md="6">
<div class="mb-3">
<strong>کشور تامین کننده:</strong>
<div>{{ workflow.supplierCountry || '-' }}</div>
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<div class="mb-3">
<strong>تلفن تامین کننده:</strong>
<div>{{ workflow.supplierPhone || '-' }}</div>
</div>
</v-col>
<v-col cols="12" md="6">
<div class="mb-3">
<strong>ایمیل تامین کننده:</strong>
<div>{{ workflow.supplierEmail || '-' }}</div>
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<div class="mb-3">
<strong>آدرس تامین کننده:</strong>
<div>{{ workflow.supplierAddress || '-' }}</div>
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="4">
<div class="mb-3">
<strong>مبلغ کل:</strong>
<div>{{ formatMoney(workflow.totalAmount) }} {{ workflow.currency }}</div>
</div>
</v-col>
<v-col cols="12" md="4">
<div class="mb-3">
<strong>نرخ تبدیل:</strong>
<div>{{ formatMoney(workflow.exchangeRate) }}</div>
</div>
</v-col>
<v-col cols="12" md="4">
<div class="mb-3">
<strong>مبلغ کل (ریال):</strong>
<div>{{ formatMoney(workflow.totalAmountIRR) }}</div>
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<div class="mb-3">
<strong>توضیحات:</strong>
<div>{{ workflow.description || '-' }}</div>
</div>
</v-col>
</v-row>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card class="mb-4">
<v-card-title>اطلاعات سیستمی</v-card-title>
<v-card-text>
<div class="mb-3">
<strong>کد پرونده:</strong>
<div>{{ workflow.code }}</div>
</div>
<div class="mb-3">
<strong>تاریخ ثبت:</strong>
<div>{{ formatDate(workflow.dateSubmit) }}</div>
</div>
<div class="mb-3">
<strong>ثبت کننده:</strong>
<div>{{ workflow.submitter }}</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Tabs -->
<v-row>
<v-col cols="12">
<v-card>
<v-tabs v-model="activeTab" bg-color="primary">
<v-tab value="items">آیتمها <v-chip size="small" color="secondary" variant="tonal" class="ms-2" style="color: white !important;">{{ workflow.items?.length || 0 }}</v-chip></v-tab>
<v-tab value="payments">پرداختها <v-chip size="small" color="secondary" variant="tonal" class="ms-2" style="color: white !important;">{{ workflow.payments?.length || 0 }}</v-chip></v-tab>
<v-tab value="documents">اسناد <v-chip size="small" color="secondary" variant="tonal" class="ms-2" style="color: white !important;">{{ workflow.documents?.length || 0 }}</v-chip></v-tab>
<v-tab value="stages">مراحل <v-chip size="small" color="secondary" variant="tonal" class="ms-2" style="color: white !important;">{{ workflow.stages?.length || 0 }}</v-chip></v-tab>
<v-tab value="shipping">حمل و نقل <v-chip size="small" color="secondary" variant="tonal" class="ms-2" style="color: white !important;">{{ workflow.shipping?.length || 0 }}</v-chip></v-tab>
<v-tab value="customs">ترخیص <v-chip size="small" color="secondary" variant="tonal" class="ms-2" style="color: white !important;">{{ workflow.customs?.length || 0 }}</v-chip></v-tab>
<!-- <v-tab value="tickets">حوالههای مرتبط</v-tab> -->
</v-tabs>
<v-tabs-window v-model="activeTab">
<v-tabs-window-item value="items">
<ImportWorkflowItems
:workflow-id="workflowId"
:items="workflow.items"
:currency="workflow.currency"
@updated="loadWorkflow"
/>
</v-tabs-window-item>
<v-tabs-window-item value="payments">
<ImportWorkflowPayments
:workflow-id="workflowId"
:payments="workflow.payments"
@updated="loadWorkflow"
/>
</v-tabs-window-item>
<v-tabs-window-item value="documents">
<ImportWorkflowDocuments
:workflow-id="workflowId"
:documents="workflow.documents"
@updated="loadWorkflow"
/>
</v-tabs-window-item>
<v-tabs-window-item value="stages">
<ImportWorkflowStages
:workflow-id="workflowId"
:stages="workflow.stages"
@updated="loadWorkflow"
/>
</v-tabs-window-item>
<v-tabs-window-item value="shipping">
<ImportWorkflowShipping
:workflow-id="workflowId"
:shipping="workflow.shipping"
@updated="loadWorkflow"
/>
</v-tabs-window-item>
<v-tabs-window-item value="customs">
<ImportWorkflowCustoms
:workflow-id="workflowId"
:customs="workflow.customs"
@updated="loadWorkflow"
/>
</v-tabs-window-item>
<v-tabs-window-item value="tickets">
<v-card flat>
<v-card-text>
<div class="d-flex align-center mb-4 gap-2">
<v-select
v-model="ticketsStatusFilter"
:items="ticketStatusOptions"
label="فیلتر وضعیت"
style="max-width: 260px"
clearable
density="compact"
variant="outlined"
@update:model-value="loadRelatedTickets"
/>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" icon="mdi-refresh" @click="loadRelatedTickets" :loading="loadingTickets" />
</div>
<v-data-table
:headers="ticketsHeaders"
:header-props="{ class: 'custom-header' }"
:items="relatedTickets"
:loading="loadingTickets"
density="comfortable"
class="elevation-1"
>
<template #item.code="{ item }">
<v-chip color="secondary" variant="tonal" size="small">{{ item.code }}</v-chip>
</template>
<template #item.status="{ item }">
<v-chip :color="ticketStatusColor(item.status)" size="small">
{{ ticketStatusLabel(item.status) }}
</v-chip>
</template>
<template #item.actions="{ item }">
<v-btn
color="primary"
variant="text"
size="small"
@click="$router.push({ name: 'storeroom_ticket_view', params: { id: item.code } })"
>
مشاهده
</v-btn>
<v-menu>
<template #activator="{ props }">
<v-btn v-bind="props" icon variant="text" size="small">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item v-for="st in ticketStatusOptions" :key="st.value" @click="updateTicketStatus(item.code, st.value)" :disabled="!st.value">
<v-list-item-title>{{ st.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-tabs-window-item>
</v-tabs-window>
</v-card>
</v-col>
</v-row>
</template>
<!-- Dialog: Create inbound storeroom ticket -->
<v-dialog v-model="showCreateTicketDialog" max-width="600">
<v-card>
<v-card-title>ایجاد حواله ورود به انبار</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12">
<v-select
v-model="selectedStoreroomId"
:items="storerooms"
item-title="name"
item-value="id"
label="انبار"
:loading="loadingStorerooms"
:disabled="loadingStorerooms"
variant="outlined"
density="compact"
required
/>
</v-col>
<v-col cols="12">
<div class="d-flex gap-2">
<v-text-field
v-model="personSearch"
label="جستجوی طرف‌حساب"
variant="outlined"
density="compact"
hide-details
/>
<v-btn color="primary" @click="searchPersons" :loading="loadingPersons">جستجو</v-btn>
</div>
<v-select
class="mt-3"
v-model="selectedPersonId"
:items="persons"
item-title="nikename"
item-value="id"
label="انتخاب طرف‌حساب"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showCreateTicketDialog = false">انصراف</v-btn>
<v-btn color="success" @click="createInboundTicket" :loading="creatingTicket" :disabled="!canCreateTicket">ایجاد حواله</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, computed } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import Swal from 'sweetalert2'
import ImportWorkflowItems from '../../../../components/plugins/import-workflow/ImportWorkflowItems.vue'
import ImportWorkflowPayments from '../../../../components/plugins/import-workflow/ImportWorkflowPayments.vue'
import ImportWorkflowDocuments from '../../../../components/plugins/import-workflow/ImportWorkflowDocuments.vue'
import ImportWorkflowStages from '../../../../components/plugins/import-workflow/ImportWorkflowStages.vue'
import ImportWorkflowShipping from '../../../../components/plugins/import-workflow/ImportWorkflowShipping.vue'
import ImportWorkflowCustoms from '../../../../components/plugins/import-workflow/ImportWorkflowCustoms.vue'
const route = useRoute()
const workflowId = route.params.id
// Data
const workflow = ref(null)
const form = ref(null)
const loading = ref(false)
const editMode = ref(false)
const valid = ref(false)
const saveLoading = ref(false)
const activeTab = ref('items')
// Create inbound ticket dialog
const showCreateTicketDialog = ref(false)
const storerooms = ref([])
const loadingStorerooms = ref(false)
const selectedStoreroomId = ref(null)
const personSearch = ref('')
const persons = ref([])
const loadingPersons = ref(false)
const selectedPersonId = ref(null)
const creatingTicket = ref(false)
const ticketsHeaders = [
{ title: 'کد حواله', key: 'code' },
{ title: 'تاریخ', key: 'date' },
{ title: 'نوع', key: 'typeString' },
{ title: 'وضعیت', key: 'status' },
{ title: 'انبار', key: 'storeroom' },
{ title: 'طرف حساب', key: 'person' },
{ title: 'عملیات', key: 'actions', sortable: false },
]
const ticketStatusOptions = [
{ title: 'همه', value: null },
{ title: 'در جریان', value: 'in_progress' },
{ title: 'تایید شده', value: 'approved' },
{ title: 'انجام شده', value: 'done' },
{ title: 'رد شده', value: 'rejected' },
{ title: 'در انتظار تایید', value: 'pending_approval' },
]
const ticketsStatusFilter = ref(null)
const relatedTickets = ref([])
const loadingTickets = ref(false)
const canCreateTicket = computed(() => !!selectedStoreroomId.value && !!selectedPersonId.value)
const openCreateTicketDialog = async () => {
showCreateTicketDialog.value = true
await loadStorerooms()
}
const loadStorerooms = async () => {
try {
loadingStorerooms.value = true
const res = await axios.get('/api/storeroom/list/active')
// انتظار میرود سرورها آرایهای از انبارها بدهد
storerooms.value = Array.isArray(res.data?.data) ? res.data.data : (Array.isArray(res.data) ? res.data : [])
} catch (e) {
storerooms.value = []
} finally {
loadingStorerooms.value = false
}
}
const searchPersons = async () => {
try {
loadingPersons.value = true
const res = await axios.post('/api/person/list/search', { search: personSearch.value || '' })
persons.value = Array.isArray(res.data) ? res.data : (Array.isArray(res.data?.Result) ? res.data.Result : [])
} catch (e) {
persons.value = []
} finally {
loadingPersons.value = false
}
}
const createInboundTicket = async () => {
if (!canCreateTicket.value || !workflow.value) return
try {
creatingTicket.value = true
const res = await axios.post(`/api/import-workflow/${workflow.value.code}/create-inbound-ticket`, {
storeroom_id: selectedStoreroomId.value,
person_id: selectedPersonId.value,
})
if (res.data?.Success) {
Swal.fire({ title: 'موفق', text: `حواله با کد ${res.data.Result.ticketCode} ایجاد شد`, icon: 'success' })
showCreateTicketDialog.value = false
await loadRelatedTickets()
} else {
throw new Error(res.data?.ErrorMessage || 'خطا در ایجاد حواله')
}
} catch (e) {
Swal.fire({ title: 'خطا', text: 'ایجاد حواله ناموفق بود', icon: 'error' })
} finally {
creatingTicket.value = false
}
}
const loadRelatedTickets = async () => {
if (!workflow.value) return
try {
loadingTickets.value = true
const qs = ticketsStatusFilter.value ? `?status=${ticketsStatusFilter.value}` : ''
const res = await axios.get(`/api/storeroom/tickets${qs}`)
const items = Array.isArray(res.data) ? res.data : []
relatedTickets.value = items.filter(t => t.importWorkflowCode === workflow.value.code)
} catch (e) {
relatedTickets.value = []
} finally {
loadingTickets.value = false
}
}
const updateTicketStatus = async (code, status) => {
try {
await axios.post(`/api/storeroom/ticket/status/${code}`, { status })
await loadRelatedTickets()
Swal.fire({ title: 'موفق', text: 'وضعیت حواله به‌روزرسانی شد', icon: 'success' })
} catch (e) {
Swal.fire({ title: 'خطا', text: 'به‌روزرسانی وضعیت ناموفق بود', icon: 'error' })
}
}
const editData = ref({})
// Currency options
const currencyOptions = [
{ title: 'دلار آمریکا (USD)', value: 'USD' },
{ title: 'یورو (EUR)', value: 'EUR' },
{ title: 'پوند انگلیس (GBP)', value: 'GBP' },
{ title: 'یوان چین (CNY)', value: 'CNY' },
{ title: 'درهم امارات (AED)', value: 'AED' },
{ title: 'ریال (IRR)', value: 'IRR' }
]
// Validation rules
const rules = {
required: (value) => !!value || 'این فیلد الزامی است',
positive: (value) => !value || parseFloat(value) > 0 || 'مقدار باید مثبت باشد',
minLength: (value) => !value || value.length >= 3 || 'حداقل 3 کاراکتر الزامی است',
maxLength: (value) => !value || value.length <= 1000 || 'حداکثر 1000 کاراکتر مجاز است',
phone: (value) => !value || /^[\d\-\+\(\)\s]+$/.test(value) || 'شماره تلفن معتبر وارد کنید',
email: (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || 'ایمیل معتبر وارد کنید',
positiveMoney: (value) => {
const numeric = parseMoneyInput(value)
return numeric > 0 || 'مقدار باید مثبت باشد'
},
exchangeRateRule: (value) => {
// اگر واحد پول IRR باشد، خالی بودن یا 1 بودن نرخ تبدیل را مجاز کن تا فرم معتبر بماند
const currentCurrency = editData.value?.currency
const numeric = parseMoneyInput(value)
if (currentCurrency === 'IRR') {
return numeric >= 0 || true
}
return numeric > 0 || 'نرخ تبدیل باید بزرگتر از صفر باشد'
}
}
// Watch for total amount and exchange rate changes
watch(
() => [editData.value.totalAmount, editData.value.exchangeRate, editData.value.currency],
([totalAmount, exchangeRate, currency]) => {
const total = parseFloat(totalAmount) || 0
const computedRate = currency === 'IRR' ? 1 : (parseFloat(exchangeRate) || 0)
const result = Math.round(total * computedRate)
editData.value.totalAmountIRR = isNaN(result) ? 0 : result
},
{ immediate: true }
)
// Methods
const loadWorkflow = async () => {
loading.value = true
try {
const response = await axios.get(`/api/import-workflow/${workflowId}`)
if (response.data.Success) {
workflow.value = response.data.Result
editData.value = { ...response.data.Result }
// Trigger the watch manually after setting editData
// مقدار ریالی بر اساس مبلغ و نرخ تبدیل/واحد پول محاسبه میشود
const total = parseFloat(editData.value.totalAmount) || 0
const rate = editData.value.currency === 'IRR' ? 1 : (parseFloat(editData.value.exchangeRate) || 0)
editData.value.totalAmountIRR = Math.round(total * rate)
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error loading workflow:', error)
Swal.fire({
title: 'خطا',
text: 'در بارگذاری جزئیات پرونده واردات خطایی رخ داد',
icon: 'error'
})
} finally {
loading.value = false
}
}
const saveChanges = async () => {
if (!isFormValidForSave.value) return
saveLoading.value = true
try {
const response = await axios.put(`/api/import-workflow/${workflowId}/update`, editData.value)
if (response.data.Success) {
Swal.fire({
title: 'موفق',
text: 'تغییرات با موفقیت ذخیره شد',
icon: 'success'
})
editMode.value = false
loadWorkflow()
} else {
throw new Error(response.data.ErrorMessage)
}
} catch (error) {
console.error('Error saving changes:', error)
Swal.fire({
title: 'خطا',
text: 'در ذخیره تغییرات خطایی رخ داد',
icon: 'error'
})
} finally {
saveLoading.value = false
}
}
// Utilities
const parseMoneyInput = (val) => {
if (val === null || val === undefined) return 0
const cleaned = String(val).replace(/,/g, '').replace(/[^\d.-]/g, '')
const num = Number(cleaned)
return Number.isFinite(num) ? num : 0
}
// دکمه ذخیره تنها زمانی فعال شود که فیلدهای کلیدی معتبر باشند
const isFormValidForSave = computed(() => {
const titleOk = typeof editData.value.title === 'string' && editData.value.title.trim().length >= 3
const supplierOk = typeof editData.value.supplierName === 'string' && editData.value.supplierName.trim().length >= 3
const currencyOk = !!editData.value.currency
const total = parseMoneyInput(editData.value.totalAmount)
const rate = parseMoneyInput(editData.value.exchangeRate)
const totalOk = total >= 0
const rateOk = editData.value.currency === 'IRR' ? rate >= 0 : rate > 0
return titleOk && supplierOk && currencyOk && totalOk && rateOk && valid.value
})
const onMoneyInput = (field, value) => {
const numeric = parseMoneyInput(value)
editData.value[field] = numeric
}
const getStatusColor = (status) => {
const colors = {
draft: 'grey',
processing: 'blue',
shipped: 'orange',
arrived: 'purple',
cleared: 'teal',
completed: 'green',
cancelled: 'red'
}
return colors[status] || 'grey'
}
const getStatusText = (status) => {
const texts = {
draft: 'پیش‌نویس',
processing: 'در حال پردازش',
shipped: 'ارسال شده',
arrived: 'رسیده',
cleared: 'ترخیص شده',
completed: 'تکمیل شده',
cancelled: 'لغو شده'
}
return texts[status] || status
}
const formatNumber = (number) => {
if (!number) return '0'
return new Intl.NumberFormat('fa-IR').format(number)
}
// نمایش مبالغ بدون اعشار و با جداکننده ویرگول بین هر سه رقم
const formatMoney = (value) => {
const numericValue = Number(value) || 0
return numericValue
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('fa-IR')
}
// استایل وضعیت حوالهها
const ticketStatusLabel = (status) => {
switch (status) {
case 'approved': return 'تایید شده'
case 'pending_approval': return 'در انتظار تایید'
case 'in_progress': return 'در حال انجام'
case 'done': return 'انجام شده'
case 'rejected': return 'رد شده'
default: return status || '-'
}
}
const ticketStatusColor = (status) => {
switch (status) {
case 'approved': return 'success'
case 'pending_approval': return 'warning'
case 'in_progress': return 'info'
case 'done': return 'primary'
case 'rejected': return 'error'
default: return 'default'
}
}
// Watch for edit mode changes
watch(editMode, async (newVal) => {
if (newVal && workflow.value) {
editData.value = { ...workflow.value }
await nextTick()
if (form.value && typeof form.value.validate === 'function') {
form.value.validate()
}
}
})
// Lifecycle
onMounted(async () => {
await loadWorkflow()
await loadRelatedTickets()
})
</script>
<style scoped>
.import-workflow-detail {
min-height: 100vh;
}
.ltr-input :deep(input) {
direction: ltr !important;
text-align: left !important;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -39,6 +39,12 @@
</v-btn>
</template>
<v-list>
<v-list-subheader color="primary">وضعیت تایید</v-list-subheader>
<v-list-item class="text-dark" title="تایید فاکتورهای انتخابی" @click="approveSelectedInvoices">
<template v-slot:prepend>
<v-icon color="green" icon="mdi-check-decagram"></v-icon>
</template>
</v-list-item>
<v-list-subheader color="primary">{{ $t('dialog.change_labels') }}</v-list-subheader>
<v-list-item v-for="item in types" class="text-dark" :title="$t('dialog.change_to') + ' ' + item.label" @click="changeLabel(item)">
<template v-slot:prepend>
@ -59,6 +65,13 @@
</template>
</v-tooltip>
</v-toolbar>
<!-- Tabs for two-step approval -->
<div v-if="business.requireTwoStepApproval" class="px-2 pt-2">
<v-tabs v-model="currentTab" color="primary" density="comfortable">
<v-tab value="approved">فاکتورهای تایید شده</v-tab>
<v-tab value="pending">فاکتورهای در انتظار تایید</v-tab>
</v-tabs>
</div>
<v-text-field hide-details color="green" class="pt-0 rounded-0 mb-0" density="compact" :placeholder="$t('dialog.search_txt')" v-model="searchValue" type="text" clearable>
<template v-slot:prepend-inner>
<v-tooltip location="bottom" :text="$t('dialog.search')">
@ -99,8 +112,8 @@
v-model:items-per-page="serverOptions.rowsPerPage"
v-model:page="serverOptions.page"
:headers="visibleHeaders"
:items="items"
:items-length="total"
:items="displayItems"
:items-length="displayTotal"
:loading="loading"
:no-data-text="$t('table.no_data')"
v-model="itemsSelected"
@ -148,6 +161,11 @@
<v-icon color="orange" icon="mdi-cloud-upload"></v-icon>
</template>
</v-list-item>
<v-list-item v-if="canShowApprovalButton(item)" class="text-dark" title="تایید فاکتور" @click="approveInvoice(item.code)">
<template v-slot:prepend>
<v-icon color="success">mdi-check-decagram</v-icon>
</template>
</v-list-item>
<v-list-item class="text-dark" :title="$t('dialog.edit')" @click="canEditItem(item.code)">
<template v-slot:prepend>
<v-icon icon="mdi-file-edit"></v-icon>
@ -206,6 +224,14 @@
</router-link>
<span v-else>-</span>
</template>
<template v-slot:item.approvalStatus="{ item }">
<v-chip size="small" :color="getApprovalStatusColor(item)">
{{ getApprovalStatusText(item) }}
</v-chip>
</template>
<template v-slot:item.approvedBy="{ item }">
{{ item.approvedBy?.fullName || '-' }}
</template>
<template v-slot:item.code="{ item }">
<router-link :to="'/acc/sell/view/' + item.code">
{{ item.code }}
@ -350,6 +376,7 @@ export default defineComponent({
data() {
let self = this;
return {
currentTab: 'approved',
paperSizes: [
{ title: self.$t('dialog.a4p'), value: 'A4' },
{ title: self.$t('dialog.a4l'), value: 'A4-L' },
@ -369,6 +396,8 @@ export default defineComponent({
invoiceIndex: true
},
plugins: {},
business: { requireTwoStepApproval: false, invoiceApprover: null },
currentUser: { email: '', owner: false },
sumSelected: 0,
sumSelectedProfit: 0,
sumTotal: 0,
@ -378,7 +407,11 @@ export default defineComponent({
loading: false,
bulkLoading: false,
items: [],
itemsApproved: [],
itemsPending: [],
total: 0,
totalApproved: 0,
totalPending: 0,
expanded: [],
serverOptions: reactive({
page: 1,
@ -391,6 +424,8 @@ export default defineComponent({
{ title: "فاکتور", value: "code", sortable: true, visible: true, width: 120 },
{ title: "تاریخ", value: "date", sortable: true, visible: true, width: 120 },
{ title: "خریدار", value: "person", sortable: true, visible: true, width: 150 },
{ title: "وضعیت تایید", value: "approvalStatus", sortable: true, visible: true, width: 150 },
{ title: "تاییدکننده", value: "approvedBy", sortable: true, visible: true, width: 120 },
{ title: "تخفیف", value: "discountAll", sortable: true, visible: true, width: 120 },
{ title: "حمل و نقل", value: "transferCost", sortable: true, visible: true, width: 120 },
{ title: "مبلغ", value: "amount", sortable: true, visible: true, width: 150 },
@ -412,13 +447,50 @@ export default defineComponent({
},
computed: {
visibleHeaders() {
return this.allHeaders.filter(header => header.visible);
return this.allHeaders.filter(header => {
// اگر ستونهای تأیید هستند، باید دو مرحلهای فعال باشد
if ((header.value === 'approvalStatus' || header.value === 'approvedBy') && !this.business.requireTwoStepApproval) {
return false;
}
return header.visible;
});
},
tableHeight() {
return window.innerHeight - 200;
},
displayItems() {
if (!this.business.requireTwoStepApproval) return this.items;
return this.currentTab === 'pending' ? this.itemsPending : this.itemsApproved;
},
displayTotal() {
if (!this.business.requireTwoStepApproval) return this.total;
return this.currentTab === 'pending' ? this.totalPending : this.totalApproved;
},
},
methods: {
approveSelectedInvoices() {
if (this.itemsSelected.length === 0) {
Swal.fire({ text: 'هیچ موردی انتخاب نشده است.', icon: 'warning', confirmButtonText: 'قبول' });
return;
}
Swal.fire({ title: 'تایید فاکتورهای انتخابی', text: 'فاکتورهای انتخاب‌شده تایید خواهند شد.', icon: 'question', showCancelButton: true, confirmButtonText: 'بله', cancelButtonText: 'خیر' })
.then(async (r) => {
if (!r.isConfirmed) return;
this.loading = true;
try {
for (const code of this.itemsSelected) {
await axios.post(`/api/sell/approve/${code}`);
}
Swal.fire({ text: 'فاکتورها تایید شدند.', icon: 'success', confirmButtonText: 'قبول' });
this.itemsSelected = [];
this.loadData();
} catch (e) {
Swal.fire({ text: 'خطا در تایید فاکتورها', icon: 'error', confirmButtonText: 'قبول' });
} finally {
this.loading = false;
}
});
},
isPluginActive(pluginCode) {
return this.plugins && this.plugins[pluginCode] !== undefined;
},
@ -431,6 +503,26 @@ export default defineComponent({
this.plugins = {};
}
},
// بارگذاری اطلاعات بیزنس
async loadBusinessInfo() {
try {
const response = await axios.get('/api/business/get/info/' + localStorage.getItem('activeBid'));
this.business = response.data || { requireTwoStepApproval: false, invoiceApprover: null };
} catch (error) {
console.error('Error loading business info:', error);
this.business = { requireTwoStepApproval: false, invoiceApprover: null };
}
},
// بارگذاری اطلاعات کاربر فعلی
async loadCurrentUser() {
try {
const response = await axios.post('/api/business/get/user/permissions');
this.currentUser = response.data || { email: '', owner: false };
} catch (error) {
console.error('Error loading current user:', error);
this.currentUser = { email: '', owner: false };
}
},
changeLabel(label) {
if (this.itemsSelected.length === 0) {
Swal.fire({
@ -494,12 +586,19 @@ export default defineComponent({
sortBy: this.serverOptions.sortBy, // ارسال اطلاعات مرتبسازی
});
this.items = (response.data.items || []).map(item => ({
const all = (response.data.items || []).map(item => ({
...item,
receivedAmount: item.relatedDocsPays || 0, // نگاشت به receivedAmount
})).filter(item => item.code && typeof item.code !== 'undefined');
this.items = all;
this.total = Number(response.data.total) || 0;
this.sumTotal = this.items.reduce((sum, item) => sum + parseInt(item.amount || 0), 0);
if (this.business.requireTwoStepApproval) {
this.itemsApproved = all.filter(i => i.isApproved === true);
this.itemsPending = all.filter(i => i.isPreview === true && i.isApproved !== true);
this.totalApproved = this.itemsApproved.length;
this.totalPending = this.itemsPending.length;
}
this.sumTotal = this.displayItems.reduce((sum, item) => sum + parseInt(item.amount || 0), 0);
} catch (error) {
console.error('Error loading data:', error);
this.items = [];
@ -514,6 +613,49 @@ export default defineComponent({
this.loading = false;
}
},
// نمایش متن وضعیت تأیید
getApprovalStatusText(item) {
if (!this.business?.requireTwoStepApproval) return 'تایید دو مرحله‌ای غیرفعال';
if (item.isPreview) return 'در انتظار تایید';
if (item.isApproved) return 'تایید شده';
return 'نامشخص';
},
// نمایش رنگ وضعیت تأیید
getApprovalStatusColor(item) {
if (!this.business?.requireTwoStepApproval) return 'default';
if (item.isPreview) return 'warning';
if (item.isApproved) return 'success';
return 'default';
},
// بررسی اینکه آیا دکمه تأیید باید نمایش داده شود
canShowApprovalButton(item) {
if (!this.business?.requireTwoStepApproval) return false;
// اگر سند قبلاً تأیید شده، دکمه تأیید نمایش داده نشود
if (item?.isApproved) return false;
// مدیر کسب و کار همیشه میتواند تأیید کند
// یا کاربر تأییدکننده فاکتور فروش
return this.business?.invoiceApprover === this.currentUser?.email || this.currentUser?.owner === true;
},
// تایید فاکتور فروش
async approveInvoice(code) {
try {
this.loading = true;
await axios.post(`/api/approval/approve/sales/${code}`);
// بهروزرسانی دادهها
await this.loadData();
Swal.fire({ text: 'فاکتور تایید شد', icon: 'success', confirmButtonText: 'قبول' });
} catch (error) {
Swal.fire({ text: 'خطا در تایید فاکتور: ' + (error.response?.data?.message || error.message), icon: 'error', confirmButtonText: 'قبول' });
} finally {
this.loading = false;
}
},
canEditItem(code) {
this.loading = true;
axios.post('/api/sell/edit/can/' + code).then((response) => {
@ -875,6 +1017,8 @@ export default defineComponent({
created() {
this.loadColumnSettings();
this.loadPlugins();
this.loadBusinessInfo();
this.loadCurrentUser();
this.loadData();
},
watch: {

View file

@ -1,4 +1,4 @@
<script lang="ts">
<script>
import { defineComponent, ref } from 'vue';
import axios from 'axios';
import Rec from '../component/rec.vue';
@ -46,12 +46,14 @@ export default defineComponent({
text: '',
color: 'error'
},
sendSerialsLoading: false,
item: {
doc: { id: 0, date: null, code: null, des: '', amount: 0, profit: 0, shortLink: null },
relatedDocs: [],
rows: [],
},
person: { nikename: null, mobile: '', tel: '', addres: '', postalcode: '' },
warrantySerials: [],
person: { id: null, nikename: null, mobile: '', tel: '', addres: '', postalcode: '' },
commoditys: [],
totalRec: 0,
totalDiscount: 0,
@ -103,6 +105,33 @@ export default defineComponent({
},
},
methods: {
copyWarrantySerials() {
const list = this.warrantySerials || [];
const text = list.map((s) => s.serialNumber).join(', ');
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text);
}
this.snackbar = { show: true, text: 'سریال‌ها کپی شد', color: 'success' };
},
async sendWarrantySerialsBySms() {
if (!this.person.mobile) {
this.snackbar = { show: true, text: 'موبایل مشتری ثبت نشده است', color: 'error' };
return;
}
if (!this.item?.doc?.code) return;
this.sendSerialsLoading = true;
try {
const res = await axios.post('/api/plugins/warranty/send-serials', {
invoiceCode: this.item.doc.code,
mobile: this.person.mobile
});
this.snackbar = { show: true, text: 'سریال‌ها برای مشتری ارسال شد', color: 'success' };
} catch (e) {
this.snackbar = { show: true, text: 'خطا در ارسال سریال‌ها', color: 'error' };
} finally {
this.sendSerialsLoading = false;
}
},
async checkCanEdit() {
try {
const response = await axios.get(`/api/sell/edit/can/${this.$route.params.id}`);
@ -131,7 +160,13 @@ export default defineComponent({
this.shortlink_url = this.item.doc.shortLink
? `${getApiUrl()}/sl/sell/${localStorage.getItem('activeBid')}/${this.item.doc.shortLink}`
: `${getApiUrl()}/sl/sell/${localStorage.getItem('activeBid')}/${this.item.doc.id}`;
this.totalRec = response.data.relatedDocs.reduce((sum: number, rdoc: any) => sum + parseInt(rdoc.amount), 0);
this.totalRec = response.data.relatedDocs.reduce((sum, rdoc) => sum + parseInt(rdoc.amount), 0);
// دریافت سریالهای گارانتی مرتبط با فاکتور
axios.get(`/api/plugins/warranty/serials/by-invoice/${this.item.doc.code}`).then((res) => {
this.warrantySerials = res.data?.items || [];
}).catch(() => {
this.warrantySerials = [];
});
});
axios.get(`/api/sell/v2/get/${this.$route.params.id}`).then((response) => {
@ -151,7 +186,7 @@ export default defineComponent({
this.totalTax = 0;
this.totalDiscount = 0;
this.commoditys = data.items.map((item: any) => {
this.commoditys = data.items.map((item) => {
this.totalTax += item.tax;
this.totalDiscount += item.discountAmount;
return {
@ -215,6 +250,14 @@ export default defineComponent({
/>
<share-options v-if="bid.shortlinks" :shortlink-url="shortlink_url" :mobile="person.mobile" :invoice-id="item.doc.id" />
<v-btn icon :disabled="!warrantySerials.length" @click="copyWarrantySerials">
<v-icon>mdi-clipboard-text</v-icon>
<v-tooltip activator="parent" location="bottom">کپی سریالهای گارانتی</v-tooltip>
</v-btn>
<v-btn icon :loading="sendSerialsLoading" :disabled="!warrantySerials.length || !person.mobile" @click="sendWarrantySerialsBySms">
<v-icon>mdi-message-processing</v-icon>
<v-tooltip activator="parent" location="bottom">ارسال سریالهای گارانتی با SMS</v-tooltip>
</v-btn>
<print-options :invoice-id="$route.params.id" />
</v-toolbar>
@ -338,6 +381,14 @@ export default defineComponent({
<!-- تب دریافتها -->
<v-window-item value="payments">
<v-card-text>
<v-alert v-if="warrantySerials.length" type="info" variant="tonal" class="mb-3">
<div class="d-flex align-center justify-space-between">
<div>
سریالهای گارانتی تخصیص یافته: {{ warrantySerials.map(s=>s.serialNumber).join('، ') }}
</div>
<v-btn size="small" color="primary" @click="copyWarrantySerials">کپی سریال‌ها</v-btn>
</div>
</v-alert>
<v-data-table v-if="item.relatedDocs.length" :header-props="{ class: 'custom-header' }" :headers="[
{ title: 'مشاهده', key: 'view' },
{ title: 'شماره', key: 'code' },

View file

@ -280,6 +280,141 @@
</v-col>
</v-row>
<h3 class="text-primary mt-4" v-if="isPluginActive('accpro')">تایید دومرحله‌ای</h3>
<v-row v-if="isPluginActive('accpro')">
<v-col cols="12">
<v-switch
v-model="content.requireTwoStepApproval"
label="فعال‌سازی تایید دومرحله‌ای برای اسناد"
color="primary"
hide-details
></v-switch>
<div class="text-caption text-medium-emphasis mt-1">
با فعالسازی این گزینه، تمام فاکتورها، حوالههای انبار، دریافتها و پرداختها نیاز به تایید مدیر خواهند داشت.
</div>
</v-col>
</v-row>
<!-- تنظیمات تاییدکنندگان -->
<v-row v-if="content.requireTwoStepApproval && isPluginActive('accpro')">
<v-col cols="12">
<v-card variant="outlined" class="pa-4">
<h4 class="text-subtitle-1 mb-3">تعیین تاییدکنندگان</h4>
<!-- تاییدکننده فاکتور فروش -->
<div class="mb-4">
<v-select
v-model="content.invoiceApprover"
:items="users"
item-title="name"
item-value="email"
label="تاییدکننده فاکتور فروش"
variant="outlined"
density="compact"
clearable
hint="کاربری که می‌تواند فاکتورهای فروش را تایید کند"
persistent-hint
>
<template v-slot:item="{ item, props }">
<v-list-item v-bind="props">
<template v-slot:prepend>
<v-icon
:color="item.owner ? 'success' : 'primary'"
size="small"
>
{{ item.owner ? 'mdi-crown' : 'mdi-account' }}
</v-icon>
</template>
<v-list-item-title>{{ item.name }}</v-list-item-title>
<v-list-item-subtitle>{{ item.email }}</v-list-item-subtitle>
</v-list-item>
</template>
</v-select>
<div class="text-caption text-medium-emphasis mt-1">
این کاربر افزون بر مدیر کسب و کار میتواند فاکتورهای فروش را تایید کند
</div>
</div>
<!-- تاییدکننده حواله انبار -->
<div class="mb-4">
<v-select
v-model="content.warehouseApprover"
:items="users"
item-title="name"
item-value="email"
label="تاییدکننده حواله انبار"
variant="outlined"
density="compact"
clearable
hint="کاربری که می‌تواند حواله‌های انبار را تایید کند"
persistent-hint
>
<template v-slot:item="{ item, props }">
<v-list-item v-bind="props">
<template v-slot:prepend>
<v-icon
:color="item.owner ? 'success' : 'primary'"
size="small"
>
{{ item.owner ? 'mdi-crown' : 'mdi-account' }}
</v-icon>
</template>
<v-list-item-title>{{ item.name }}</v-list-item-title>
<v-list-item-subtitle>{{ item.email }}</v-list-item-subtitle>
</v-list-item>
</template>
</v-select>
<div class="text-caption text-medium-emphasis mt-1">
این کاربر افزون بر مدیر کسب و کار میتواند حوالههای انبار را تایید کند
</div>
</div>
<!-- تاییدکننده دریافت و پرداخت مالی -->
<!-- <div class="mb-4">
<v-select
v-model="content.financialApprover"
:items="users"
item-title="name"
item-value="email"
label="تاییدکننده دریافت و پرداخت مالی"
variant="outlined"
density="compact"
clearable
hint="کاربری که می‌تواند دریافت‌ها و پرداخت‌ها را تایید کند"
persistent-hint
>
<template v-slot:item="{ item, props }">
<v-list-item v-bind="props">
<template v-slot:prepend>
<v-icon
:color="item.owner ? 'success' : 'primary'"
size="small"
>
{{ item.owner ? 'mdi-crown' : 'mdi-account' }}
</v-icon>
</template>
<v-list-item-title>{{ item.name }}</v-list-item-title>
<v-list-item-subtitle>{{ item.email }}</v-list-item-subtitle>
</v-list-item>
</template>
</v-select>
<div class="text-caption text-medium-emphasis mt-1">
این کاربر افزون بر مدیر کسب و کار میتواند دریافتها و پرداختها را تایید کند
</div>
</div> -->
<div class="text-caption text-medium-emphasis">
<v-icon size="small" color="info" class="me-1">mdi-information</v-icon>
در صورت عدم انتخاب تاییدکننده، فقط مدیر کسب و کار میتواند اسناد را تایید کند
</div>
<div class="text-caption text-medium-emphasis mt-2">
<v-icon size="small" color="success" class="me-1">mdi-check-circle</v-icon>
<strong>نکته:</strong> صاحب کسب و کار همیشه میتواند تمام اسناد را تأیید کند و نیازی به تعیین مجدد ندارد
</div>
</v-card>
</v-col>
</v-row>
<h3 class="text-primary mt-4">کالا و خدمات</h3>
<v-row>
<v-col cols="12" md="8">
@ -365,6 +500,10 @@ export default {
shortlinks: false,
walletEnabled: false,
walletMatchBank: null,
requireTwoStepApproval: false,
invoiceApprover: null,
warehouseApprover: null,
financialApprover: null,
year: {
startShamsi: '',
endShamsi: '',
@ -374,7 +513,9 @@ export default {
updateBuyPrice: false,
profitCalcType: 'lis'
},
users: [],
listBanks: [],
plugins: []
}
},
watch: {
@ -387,6 +528,9 @@ export default {
}
},
methods: {
isPluginActive(plugName) {
return this.plugins[plugName] !== undefined;
},
checkBanksExist() {
if (this.listBanks.length === 0) {
Swal.fire({
@ -448,6 +592,10 @@ export default {
'shortlinks': this.content.shortlinks,
'walletEnabled': this.content.walletEnabled,
'walletMatchBank': this.content.walletMatchBank,
'requireTwoStepApproval': this.content.requireTwoStepApproval,
'invoiceApprover': this.content.invoiceApprover,
'warehouseApprover': this.content.warehouseApprover,
'financialApprover': this.content.financialApprover,
'year': this.content.year,
'commodityUpdateBuyPriceAuto': this.content.updateBuyPrice,
'commodityUpdateSellPriceAuto': this.content.updateSellPrice,
@ -484,27 +632,56 @@ export default {
},
async beforeMount() {
this.loading = true
//get all money types
axios.post("/api/money/get/all").then((response) => {
this.moneys = response.data;
this.content.arzmain = this.moneys[0];
})
//get list of banks
await axios.post('/api/bank/list').then((response) => {
this.listBanks = response.data;
});
try {
// ابتدا اطلاعات کسب و کار را بارگذاری کن
const businessResponse = await axios.post('/api/business/get/info/' + localStorage.getItem('activeBid'));
this.content = businessResponse.data;
//get business info
let data = await axios.post('/api/business/get/info/' + localStorage.getItem('activeBid'))
.then((response) => {
this.content = response.data;
// اگر walletMatchBank یک آبجکت است، فقط id آن را نگه میداریم
if (this.content.walletMatchBank && typeof this.content.walletMatchBank === 'object') {
this.content.walletMatchBank = this.content.walletMatchBank.id;
}
this.loading = false;
// اگر walletMatchBank یک آبجکت است، فقط id آن را نگه میداریم
if (this.content.walletMatchBank && typeof this.content.walletMatchBank === 'object') {
this.content.walletMatchBank = this.content.walletMatchBank.id;
}
// اطمینان از وجود فیلدهای تأییدکننده
if (!this.content.hasOwnProperty('invoiceApprover')) {
this.content.invoiceApprover = null;
}
if (!this.content.hasOwnProperty('warehouseApprover')) {
this.content.warehouseApprover = null;
}
if (!this.content.hasOwnProperty('financialApprover')) {
this.content.financialApprover = null;
}
if (!this.content.hasOwnProperty('requireTwoStepApproval')) {
this.content.requireTwoStepApproval = false;
}
// سپس سایر دادهها را بارگذاری کن
const [moneyResponse, banksResponse, usersResponse, pluginsResponse] = await Promise.all([
axios.post("/api/money/get/all"),
axios.post('/api/bank/list'),
axios.get(`/api/user/get/users/of/business/${localStorage.getItem('activeBid')}`),
axios.post(`/api/plugin/get/actives`)
]);
this.plugins = pluginsResponse.data;
this.moneys = moneyResponse.data;
this.content.arzmain = this.moneys[0];
this.listBanks = banksResponse.data;
// حذف صاحب کسب و کار از لیست کاربران تأییدکننده
this.users = usersResponse.data.filter(user => !user.owner);
} catch (error) {
console.error('Error loading data:', error);
Swal.fire({
text: 'خطا در بارگذاری اطلاعات',
icon: 'error',
confirmButtonText: 'قبول'
});
} finally {
this.loading = false;
}
}
}
</script>

View file

@ -402,6 +402,52 @@
</v-col>
</v-row>
<!-- بخش دسترسی انباردار -->
<v-row class="mt-4">
<v-col cols="12">
<v-card-title class="text-h6 font-weight-bold mb-4 text-primary">
<v-icon color="primary" class="me-2">mdi-warehouse</v-icon>
دسترسی انباردار
</v-card-title>
<v-alert
type="info"
variant="tonal"
class="warehouse-alert mr-4"
density="compact"
border="start"
border-color="primary"
>
<template v-slot:prepend>
<v-icon color="primary">mdi-information</v-icon>
</template>
<div class="text-body-2">
دسترسی انباردار شامل تمام بخشهای انبارداری مانند مدیریت انبارها، کالاها، حوالهها، موجودی و گارانتی میباشد.
</div>
</v-alert>
</v-col>
<v-col cols="12" md="6">
<v-card variant="outlined" class="h-100">
<v-card-text>
<v-list>
<v-list-item>
<v-switch
v-model="info.warehouseManager"
label="دسترسی کامل انباردار"
@change="savePerms('warehouseManager')"
hide-details
color="primary"
density="comfortable"
:loading="loadingSwitches.warehouseManager"
:disabled="loadingSwitches.warehouseManager"
></v-switch>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row v-if="isPluginActive('accpro')" class="mt-4">
<v-col cols="12">
<v-card-title class="text-h6 font-weight-bold mb-4">بسته حسابداری پیشرفته</v-card-title>
@ -732,7 +778,8 @@ export default {
plugGhestaManager: false,
plugTaxSettings: false,
inquiry: false,
ai: false
ai: false,
warehouseManager: false
};
axios.post('/api/business/get/user/permissions',
@ -809,4 +856,16 @@ export default {
.v-switch.v-input--disabled {
opacity: 0.7;
}
.warehouse-alert {
width: 45%;
}
@media (max-width: 600px) {
.warehouse-alert {
width: 100%;
margin-right: 0px !important;
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -53,8 +53,12 @@
<v-window-item value="output">
<v-text-field v-model="outputSearchValue" prepend-inner-icon="mdi-magnify" label="جستجو" variant="outlined"
density="compact" hide-details class="mb-1"></v-text-field>
<v-tabs v-if="business.requireTwoStepApproval" v-model="outputSubTab" color="primary" density="comfortable" class="mb-2">
<v-tab value="approved">تایید شده</v-tab>
<v-tab value="pending">در انتظار تایید</v-tab>
</v-tabs>
<v-data-table :headers="visibleHeaders" :items="outputItems" :search="outputSearchValue" :loading="loading"
<v-data-table :headers="visibleHeaders" :items="displayOutputItems" :search="outputSearchValue" :loading="loading"
hover density="compact" class="elevation-1 text-center"
:header-props="{ class: 'custom-header' }">
<template v-slot:item="{ item }">
@ -71,6 +75,12 @@
</template>
<v-list-item-title>مشاهده</v-list-item-title>
</v-list-item>
<v-list-item v-if="canShowApprovalButton(item)" @click="approveTicket(item.code)">
<template v-slot:prepend>
<v-icon color="primary">mdi-check-decagram</v-icon>
</template>
<v-list-item-title>تایید حواله</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteTicket('output', item.code)">
<template v-slot:prepend>
<v-icon color="error">mdi-delete</v-icon>
@ -80,9 +90,17 @@
</v-list>
</v-menu>
</td>
<td v-if="isColumnVisible('code')" class="text-center">{{ formatNumber(item.code) }}</td>
<td v-if="isColumnVisible('code')" class="text-center">{{ item.code }}</td>
<td v-if="isColumnVisible('date')" class="text-center">{{ item.date }}</td>
<td v-if="isColumnVisible('doc.code')" class="text-center">{{ item.doc.code }}</td>
<td v-if="isColumnVisible('approvalStatus')" class="text-center">
<v-chip size="small" :color="getApprovalStatusColor(item)">
{{ getApprovalStatusText(item) }}
</v-chip>
</td>
<td v-if="isColumnVisible('approvedBy')" class="text-center">
{{ item.approvedBy?.fullName || '-' }}
</td>
<td v-if="isColumnVisible('doc.code')" class="text-center">{{ item.doc ? item.doc.code : '-' }}</td>
<td v-if="isColumnVisible('person.nikename')" class="text-center">{{ item.person.nikename }}</td>
<td v-if="isColumnVisible('des')" class="text-center">{{ item.des }}</td>
</tr>
@ -94,8 +112,12 @@
<v-window-item value="input">
<v-text-field v-model="inputSearchValue" prepend-inner-icon="mdi-magnify" label="جستجو" variant="outlined"
density="compact" hide-details class="mb-1"></v-text-field>
<v-tabs v-if="business.requireTwoStepApproval" v-model="inputSubTab" color="primary" density="comfortable" class="mb-2">
<v-tab value="approved">تایید شده</v-tab>
<v-tab value="pending">در انتظار تایید</v-tab>
</v-tabs>
<v-data-table :headers="visibleHeaders" :items="inputItems" :search="inputSearchValue" :loading="loading" hover
<v-data-table :headers="visibleHeaders" :items="displayInputItems" :search="inputSearchValue" :loading="loading" hover
density="compact" class="elevation-1 text-center"
:header-props="{ class: 'custom-header' }">
<template v-slot:item="{ item }">
@ -112,6 +134,12 @@
</template>
<v-list-item-title>مشاهده</v-list-item-title>
</v-list-item>
<v-list-item v-if="canShowApprovalButton(item)" @click="approveTicket(item.code)">
<template v-slot:prepend>
<v-icon color="success">mdi-check-decagram</v-icon>
</template>
<v-list-item-title>تایید حواله</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteTicket('input', item.code)">
<template v-slot:prepend>
<v-icon color="error">mdi-delete</v-icon>
@ -121,9 +149,17 @@
</v-list>
</v-menu>
</td>
<td v-if="isColumnVisible('code')" class="text-center">{{ formatNumber(item.code) }}</td>
<td v-if="isColumnVisible('code')" class="text-center">{{ item.code }}</td>
<td v-if="isColumnVisible('date')" class="text-center">{{ item.date }}</td>
<td v-if="isColumnVisible('doc.code')" class="text-center">{{ item.doc.code }}</td>
<td v-if="isColumnVisible('approvalStatus')" class="text-center">
<v-chip size="small" :color="getApprovalStatusColor(item)">
{{ getApprovalStatusText(item) }}
</v-chip>
</td>
<td v-if="isColumnVisible('approvedBy')" class="text-center">
{{ item.approvedBy?.fullName || '-' }}
</td>
<td v-if="isColumnVisible('doc.code')" class="text-center">{{ item.doc ? item.doc.code : '-' }}</td>
<td v-if="isColumnVisible('person.nikename')" class="text-center">{{ item.person.nikename }}</td>
<td v-if="isColumnVisible('des')" class="text-center">{{ item.des }}</td>
</tr>
@ -135,8 +171,12 @@
<v-window-item value="transfer">
<v-text-field v-model="transferSearchValue" prepend-inner-icon="mdi-magnify" label="جستجو" variant="outlined"
density="compact" hide-details class="mb-1"></v-text-field>
<v-tabs v-if="business.requireTwoStepApproval" v-model="transferSubTab" color="primary" density="comfortable" class="mb-2">
<v-tab value="approved">تایید شده</v-tab>
<v-tab value="pending">در انتظار تایید</v-tab>
</v-tabs>
<v-data-table :headers="visibleHeaders" :items="transferItems" :search="transferSearchValue" :loading="loading" hover
<v-data-table :headers="visibleHeaders" :items="displayTransferItems" :search="transferSearchValue" :loading="loading" hover
density="compact" class="elevation-1 text-center"
:header-props="{ class: 'custom-header' }">
<template v-slot:item="{ item }">
@ -153,6 +193,12 @@
</template>
<v-list-item-title>مشاهده</v-list-item-title>
</v-list-item>
<v-list-item v-if="canShowApprovalButton(item)" @click="approveTicket(item.code)">
<template v-slot:prepend>
<v-icon color="primary">mdi-check-decagram</v-icon>
</template>
<v-list-item-title>تایید حواله</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteTicket('transfer', item.code)">
<template v-slot:prepend>
<v-icon color="error">mdi-delete</v-icon>
@ -162,9 +208,17 @@
</v-list>
</v-menu>
</td>
<td v-if="isColumnVisible('code')" class="text-center">{{ formatNumber(item.code) }}</td>
<td v-if="isColumnVisible('code')" class="text-center">{{ item.code }}</td>
<td v-if="isColumnVisible('date')" class="text-center">{{ item.date }}</td>
<td v-if="isColumnVisible('doc.code')" class="text-center">{{ item.doc.code }}</td>
<td v-if="isColumnVisible('approvalStatus')" class="text-center">
<v-chip size="small" :color="getApprovalStatusColor(item)">
{{ getApprovalStatusText(item) }}
</v-chip>
</td>
<td v-if="isColumnVisible('approvedBy')" class="text-center">
{{ item.approvedBy?.fullName || '-' }}
</td>
<td v-if="isColumnVisible('doc.code')" class="text-center">{{ item.doc ? item.doc.code : '-' }}</td>
<td v-if="isColumnVisible('person.nikename')" class="text-center">{{ item.person.nikename }}</td>
<td v-if="isColumnVisible('des')" class="text-center">{{ item.des }}</td>
</tr>
@ -176,8 +230,12 @@
<v-window-item value="waste">
<v-text-field v-model="wasteSearchValue" prepend-inner-icon="mdi-magnify" label="جستجو" variant="outlined"
density="compact" hide-details class="mb-1"></v-text-field>
<v-tabs v-if="business.requireTwoStepApproval" v-model="wasteSubTab" color="primary" density="comfortable" class="mb-2">
<v-tab value="approved">تایید شده</v-tab>
<v-tab value="pending">در انتظار تایید</v-tab>
</v-tabs>
<v-data-table :headers="visibleHeaders" :items="wasteItems" :search="wasteSearchValue" :loading="loading" hover
<v-data-table :headers="visibleHeaders" :items="displayWasteItems" :search="wasteSearchValue" :loading="loading" hover
density="compact" class="elevation-1 text-center"
:header-props="{ class: 'custom-header' }">
<template v-slot:item="{ item }">
@ -190,10 +248,16 @@
<v-list>
<v-list-item :to="'/acc/storeroom/ticket/view/' + item.code">
<template v-slot:prepend>
<v-icon color="success">mdi-eye</v-icon>
<v-icon color="success">mdi-check-decagram</v-icon>
</template>
<v-list-item-title>مشاهده</v-list-item-title>
</v-list-item>
<v-list-item v-if="canShowApprovalButton(item)" @click="approveTicket(item.code)">
<template v-slot:prepend>
<v-icon color="primary">mdi-check-decagram</v-icon>
</template>
<v-list-item-title>تایید حواله</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteTicket('waste', item.code)">
<template v-slot:prepend>
<v-icon color="error">mdi-delete</v-icon>
@ -203,9 +267,17 @@
</v-list>
</v-menu>
</td>
<td v-if="isColumnVisible('code')" class="text-center">{{ formatNumber(item.code) }}</td>
<td v-if="isColumnVisible('code')" class="text-center">{{ item.code }}</td>
<td v-if="isColumnVisible('date')" class="text-center">{{ item.date }}</td>
<td v-if="isColumnVisible('doc.code')" class="text-center">{{ item.doc.code }}</td>
<td v-if="isColumnVisible('approvalStatus')" class="text-center">
<v-chip size="small" :color="getApprovalStatusColor(item)">
{{ getApprovalStatusText(item) }}
</v-chip>
</td>
<td v-if="isColumnVisible('approvedBy')" class="text-center">
{{ item.approvedBy?.fullName || '-' }}
</td>
<td v-if="isColumnVisible('doc.code')" class="text-center">{{ item.doc ? item.doc.code : '-' }}</td>
<td v-if="isColumnVisible('person.nikename')" class="text-center">{{ item.person.nikename }}</td>
<td v-if="isColumnVisible('des')" class="text-center">{{ item.des }}</td>
</tr>
@ -267,17 +339,26 @@ interface Ticket {
date: string;
doc: {
code: string;
preview?: boolean;
approved?: boolean;
};
person: {
nikename: string;
};
des: string;
preview?: boolean;
approved?: boolean;
approvedBy?: {
fullName: string;
email: string;
id: number;
};
}
interface Header {
title: string;
key: string;
align: string;
align?: 'start' | 'center' | 'end';
sortable: boolean;
width: number;
visible: boolean;
@ -294,7 +375,16 @@ const outputSearchValue = ref('');
const transferSearchValue = ref('');
const wasteSearchValue = ref('');
const activeTab = ref('output');
const inputSubTab = ref<'approved' | 'pending'>('approved');
const outputSubTab = ref<'approved' | 'pending'>('approved');
const transferSubTab = ref<'approved' | 'pending'>('approved');
const wasteSubTab = ref<'approved' | 'pending'>('approved');
const showColumnDialog = ref(false);
const business = ref({
requireTwoStepApproval: false,
warehouseApprover: null
});
const currentUser = ref({ email: '', owner: false });
// دیالوگها
const deleteDialog = ref({
@ -311,22 +401,38 @@ const snackbar = ref({
// تعریف همه ستونها
const allHeaders = ref<Header[]>([
{ title: "عملیات", key: "operation", align: 'center', sortable: false, width: 100, visible: true },
{ title: "شماره", key: "code", align: 'center', sortable: true, width: 100, visible: true },
{ title: "تاریخ", key: "date", align: 'center', sortable: true, width: 120, visible: true },
{ title: "شماره فاکتور", key: "doc.code", align: 'center', sortable: true, width: 120, visible: true },
{ title: "شخص", key: "person.nikename", align: 'center', sortable: true, width: 120, visible: true },
{ title: "توضیحات", key: "des", align: 'center', sortable: true, width: 200, visible: true },
{ title: "عملیات", key: "operation", align: 'center' as const, sortable: false, width: 100, visible: true },
{ title: "شماره", key: "code", align: 'center' as const, sortable: true, width: 100, visible: true },
{ title: "تاریخ", key: "date", align: 'center' as const, sortable: true, width: 120, visible: true },
{ title: "وضعیت تایید", key: "approvalStatus", align: 'center' as const, sortable: true, width: 150, visible: true },
{ title: "تاییدکننده", key: "approvedBy", align: 'center' as const, sortable: true, width: 120, visible: true },
{ title: "شماره فاکتور", key: "doc.code", align: 'center' as const, sortable: true, width: 120, visible: true },
{ title: "شخص", key: "person.nikename", align: 'center' as const, sortable: true, width: 120, visible: true },
{ title: "توضیحات", key: "des", align: 'center' as const, sortable: true, width: 200, visible: true },
]);
// ستونهای قابل نمایش
const visibleHeaders = computed(() => {
return allHeaders.value.filter((header: Header) => header.visible);
return allHeaders.value.filter((header: Header) => {
// اگر ستونهای تأیید هستند، باید دو مرحلهای فعال باشد
if ((header.key === 'approvalStatus' || header.key === 'approvedBy') && !business.value.requireTwoStepApproval) {
return false;
}
return header.visible;
}) as any;
});
// بررسی نمایش ستون
const isColumnVisible = (key: string) => {
return allHeaders.value.find((header: Header) => header.key === key)?.visible;
const header = allHeaders.value.find((header: Header) => header.key === key);
if (!header) return false;
// اگر ستونهای تأیید هستند، باید دو مرحلهای فعال باشد
if ((key === 'approvalStatus' || key === 'approvedBy') && !business.value.requireTwoStepApproval) {
return false;
}
return header.visible;
};
// کلید ذخیرهسازی در localStorage
@ -357,6 +463,57 @@ const formatNumber = (value: string | number) => {
return Number(value).toLocaleString('fa-IR');
};
// فهرستهای نمایشی بر اساس ساب-تب و دو مرحلهای
const displayInputItems = computed(() => {
if (!business.value.requireTwoStepApproval) return inputItems.value;
return inputSubTab.value === 'pending'
? inputItems.value.filter(i => i.preview && !i.approved)
: inputItems.value.filter(i => i.approved);
});
const displayOutputItems = computed(() => {
if (!business.value.requireTwoStepApproval) return outputItems.value;
return outputSubTab.value === 'pending'
? outputItems.value.filter(i => i.preview && !i.approved)
: outputItems.value.filter(i => i.approved);
});
const displayTransferItems = computed(() => {
if (!business.value.requireTwoStepApproval) return transferItems.value;
return transferSubTab.value === 'pending'
? transferItems.value.filter(i => i.preview && !i.approved)
: transferItems.value.filter(i => i.approved);
});
const displayWasteItems = computed(() => {
if (!business.value.requireTwoStepApproval) return wasteItems.value;
return wasteSubTab.value === 'pending'
? wasteItems.value.filter(i => i.preview && !i.approved)
: wasteItems.value.filter(i => i.approved);
});
// بارگذاری اطلاعات بیزنس
const loadBusinessInfo = async () => {
try {
const response = await axios.get('/api/business/get/info/' + localStorage.getItem('activeBid'));
business.value = response.data || { requireTwoStepApproval: false, warehouseApprover: null };
} catch (error: any) {
console.error('Error loading business info:', error);
business.value = { requireTwoStepApproval: false, warehouseApprover: null };
}
};
// بارگذاری اطلاعات کاربر فعلی
const loadCurrentUser = async () => {
try {
const response = await axios.post('/api/business/get/user/permissions');
currentUser.value = response.data || { email: '', owner: false };
} catch (error: any) {
console.error('Error loading current user:', error);
currentUser.value = { email: '', owner: false };
}
};
// بارگذاری دادهها
const loadData = async () => {
loading.value = true;
@ -367,10 +524,10 @@ const loadData = async () => {
axios.post('/api/storeroom/tickets/list/transfer'),
axios.post('/api/storeroom/tickets/list/waste')
]);
inputItems.value = inputResponse.data;
outputItems.value = outputResponse.data;
transferItems.value = transferResponse.data;
wasteItems.value = wasteResponse.data;
inputItems.value = (inputResponse.data || []);
outputItems.value = (outputResponse.data || []);
transferItems.value = (transferResponse.data || []);
wasteItems.value = (wasteResponse.data || []);
} catch (error: any) {
console.error('Error loading data:', error);
snackbar.value = {
@ -383,6 +540,53 @@ const loadData = async () => {
}
};
// بررسی اینکه آیا دکمه تأیید باید نمایش داده شود
const canShowApprovalButton = (item: Ticket) => {
if (!business.value.requireTwoStepApproval) return false;
// اگر سند قبلاً تأیید شده، دکمه تأیید نمایش داده نشود
if (item?.approved) return false;
// مدیر کسب و کار همیشه میتواند تأیید کند
// یا کاربر تأییدکننده انبار
return business.value.warehouseApprover === currentUser.value.email || currentUser.value.owner === true;
};
// تایید حواله
const approveTicket = async (code: string) => {
try {
loading.value = true;
await axios.post(`/api/approval/approve/storeroom/${code}`);
// بهروزرسانی دادهها
await loadData();
snackbar.value = { show: true, message: 'حواله تایید شد', color: 'success' };
} catch (error: any) {
snackbar.value = { show: true, message: 'خطا در تایید حواله: ' + (error.response?.data?.message || error.message), color: 'error' };
} finally {
loading.value = false;
}
};
// نمایش متن وضعیت تأیید
const getApprovalStatusText = (item: Ticket) => {
if (!business.value.requireTwoStepApproval) return 'تایید دو مرحله‌ای غیرفعال';
if (item?.preview) return 'در انتظار تایید';
if (item?.approved) return 'تایید شده';
return 'نامشخص';
};
// نمایش رنگ وضعیت تأیید
const getApprovalStatusColor = (item: Ticket) => {
if (!business.value.requireTwoStepApproval) return 'default';
if (item?.preview) return 'warning';
if (item?.approved) return 'success';
return 'default';
};
// حذف حواله
const deleteTicket = (type: 'input' | 'output' | 'transfer' | 'waste', code: string) => {
deleteDialog.value = {
@ -438,6 +642,8 @@ const confirmDelete = async () => {
// مانت کامپوننت
onMounted(() => {
loadColumnSettings();
loadBusinessInfo();
loadCurrentUser();
loadData();
});
</script>

View file

@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import axios from 'axios'
import { useRouter } from 'vue-router'
import moment from 'jalali-moment'
interface Business {
legal_name: string
@ -19,6 +20,8 @@ interface Ticket {
type: string
typeString: string
storeroom: Storeroom
preview: boolean
approved: boolean
}
interface Person {
@ -70,7 +73,9 @@ const item = ref<Item>({
typeString: '',
storeroom: {
manager: ''
}
},
preview: false,
approved: false
},
rows: [],
person: {
@ -128,8 +133,150 @@ const printInvoice = async () => {
}
}
// Attachments
const attachments = ref<any[]>([])
const loadingAttachments = ref(false)
const uploading = ref(false)
const selectedFile = ref<File | null>(null)
const attachDes = ref('')
const canPrint = computed(() => {
const st = (item.value as any)?.ticket?.status as string | undefined
if (!st) return true
return st === 'approved' || st === 'done' || st === 'in_progress'
})
const isAttachmentsDisabled = computed(() => {
return item.value.ticket.preview
})
const loadAttachments = async () => {
try {
loadingAttachments.value = true
const res = await axios.get(`/api/storeroom/ticket/attachments/${router.currentRoute.value.params.id}`)
attachments.value = Array.isArray(res.data) ? res.data : []
} catch (e) {
attachments.value = []
} finally {
loadingAttachments.value = false
}
}
const uploadAttachment = async () => {
if (!selectedFile.value) return
try {
uploading.value = true
const fd = new FormData()
fd.append('file', selectedFile.value)
if (attachDes.value) fd.append('des', attachDes.value)
await axios.post(`/api/storeroom/ticket/attachments/upload/${router.currentRoute.value.params.id}`, fd, {
headers: { 'Content-Type': 'multipart/form-data' }
})
selectedFile.value = null
attachDes.value = ''
await loadAttachments()
} catch (e) {
// خطا نمایش داده نمیشود تا ساده بماند
} finally {
uploading.value = false
}
}
const downloadAttachment = async (att: any) => {
try {
const url = `/api/storeroom/ticket/attachments/download/${att.id}`
const res = await axios.get(url, { responseType: 'blob' })
const blob = new Blob([res.data], { type: res.headers['content-type'] || 'application/octet-stream' })
const blobUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
const base = (att.filename || '').split('/').pop() || `attachment-${att.id}`
link.download = base
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(blobUrl)
} catch (e) {
// Silent for now or show toast
}
}
// UX helpers for attachments
const getFileName = (path?: string) => {
if (!path) return 'بدون‌نام'
const clean = String(path).replace(/\\/g, '/')
const parts = clean.split('/')
return parts[parts.length - 1] || clean
}
const formatSize = (size?: string | number | null) => {
const n = Number(size || 0)
if (!isFinite(n) || n <= 0) return '-'
const units = ['B', 'KB', 'MB', 'GB']
let idx = 0
let val = n
while (val >= 1024 && idx < units.length - 1) {
val /= 1024
idx++
}
return `${val.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`
}
const getMimeIcon = (mime?: string | null) => {
const m = (mime || '').toLowerCase()
if (m.startsWith('image/')) return 'mdi-image'
if (m.includes('pdf')) return 'mdi-file-pdf-box'
if (m.includes('word') || m.endsWith('/msword') || m.includes('officedocument.word')) return 'mdi-file-word-box'
if (m.includes('excel') || m.includes('spreadsheet')) return 'mdi-file-excel-box'
if (m.includes('zip') || m.includes('rar') || m.includes('7z')) return 'mdi-zip-box'
if (m.includes('text')) return 'mdi-file-document-outline'
return 'mdi-file'
}
const isPreviewable = (mime?: string | null) => {
const m = (mime || '').toLowerCase()
return m.startsWith('image/') || m.includes('pdf')
}
const previewDialog = ref(false)
const previewUrl = ref<string | null>(null)
const previewMime = ref<string | null>(null)
const previewName = ref<string>('')
const closePreview = () => {
if (previewUrl.value) {
try { window.URL.revokeObjectURL(previewUrl.value) } catch {}
}
previewUrl.value = null
previewMime.value = null
previewName.value = ''
previewDialog.value = false
}
const previewAttachment = async (att: any) => {
try {
const mime = att.fileType as string | undefined
if (!isPreviewable(mime)) {
await downloadAttachment(att)
return
}
const url = `/api/storeroom/ticket/attachments/download/${att.id}`
const res = await axios.get(url, { responseType: 'blob' })
const blob = new Blob([res.data], { type: res.headers['content-type'] || mime || 'application/octet-stream' })
const blobUrl = window.URL.createObjectURL(blob)
previewUrl.value = blobUrl
previewMime.value = mime || res.headers['content-type'] || 'application/octet-stream'
previewName.value = getFileName(att.filename)
previewDialog.value = true
} catch (e) {
// fallback to download on error
await downloadAttachment(att)
}
}
onMounted(() => {
loadData()
loadAttachments()
})
</script>
@ -150,6 +297,7 @@ onMounted(() => {
@click="printInvoice"
color="primary"
icon="mdi-printer"
:disabled="!canPrint"
/>
</template>
</v-tooltip>
@ -293,6 +441,117 @@ onMounted(() => {
</v-data-table>
</v-card-text>
</v-card>
<v-card variant="outlined" class="mt-4" :class="{ 'opacity-50': isAttachmentsDisabled }">
<v-card-title class="text-subtitle-1 font-weight-bold">
<v-icon start>mdi-paperclip</v-icon>
پیوستها
<v-chip v-if="isAttachmentsDisabled" color="warning" size="small" class="ml-2">در انتظار تایید</v-chip>
</v-card-title>
<v-card-text>
<div class="d-flex align-center gap-2 mb-3">
<v-file-input
v-model="selectedFile"
label="انتخاب فایل"
accept="image/*,.pdf,.jpg,.jpeg,.png"
variant="outlined"
density="compact"
hide-details
style="max-width: 360px"
:disabled="isAttachmentsDisabled"
/>
<v-text-field
v-model="attachDes"
label="توضیحات"
variant="outlined"
density="compact"
hide-details
:disabled="isAttachmentsDisabled"
/>
<v-btn color="primary" :loading="uploading" :disabled="!selectedFile || isAttachmentsDisabled" @click="uploadAttachment">آپلود</v-btn>
<v-spacer />
<v-btn variant="text" icon="mdi-refresh" :loading="loadingAttachments" :disabled="isAttachmentsDisabled" @click="loadAttachments" />
</div>
<v-data-table
:headers="[
{ title: 'نام فایل', key: 'fileDisplay' },
{ title: 'نوع', key: 'fileType' },
{ title: 'حجم', key: 'fileSize' },
{ title: 'توضیحات', key: 'des' },
{ title: 'تاریخ', key: 'dateSubmit' },
{ title: 'عملیات', key: 'actions' },
]"
:items="attachments"
:loading="loadingAttachments"
density="comfortable"
class="elevation-1"
>
<template #item.fileDisplay="{ item }">
<div class="d-flex align-center justify-center">
<span class="text-truncate" style="max-width: 320px">{{ getFileName(item.filename) }}</span>
</div>
</template>
<template #item.fileType="{ item }">
<code class="text-caption text-center d-flex align-center justify-center text-black">{{ item.fileType.split('/')[1].toUpperCase() || '-' }}<v-icon class="ml-2">{{ getMimeIcon(item.fileType) }}</v-icon></code>
</template>
<template #item.fileSize="{ item }">
{{ formatSize(item.fileSize) }}
</template>
<template #item.dateSubmit="{ item }">
{{ moment(item.dateSubmit, 'YYYY/MM/DD').locale('fa').format('YYYY/MM/DD') }}
</template>
<template #item.actions="{ item }">
<v-btn
icon
variant="text"
size="small"
title="دانلود"
@click="downloadAttachment(item)"
>
<v-icon>mdi-download</v-icon>
</v-btn>
<v-btn
icon
variant="text"
size="small"
title="پیش‌نمایش"
@click="previewAttachment(item)"
>
<v-icon>mdi-eye</v-icon>
</v-btn>
</template>
</v-data-table>
</v-card-text>
</v-card>
<!-- Preview dialog -->
<v-dialog v-model="previewDialog" max-width="900px">
<v-card>
<v-toolbar density="compact" color="primary">
<v-toolbar-title class="text-subtitle-2 text-white">
<v-icon start color="white">mdi-file-eye</v-icon>
{{ previewName }}
</v-toolbar-title>
<v-spacer />
<v-btn icon @click="closePreview"><v-icon color="white">mdi-close</v-icon></v-btn>
</v-toolbar>
<v-card-text class="pa-0">
<div v-if="previewUrl && previewMime?.includes('pdf')" style="height:70vh">
<iframe :src="previewUrl" style="width:100%;height:100%" frameborder="0"></iframe>
</div>
<div v-else-if="previewUrl && previewMime?.startsWith('image/')" class="d-flex justify-center">
<img :src="previewUrl" :alt="previewName" style="max-width:100%;max-height:70vh" />
</div>
<div v-else class="pa-6 text-center">
پیشنمایش در دسترس نیست. لطفاً دانلود کنید.
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" @click="() => { /* fallback */ closePreview() }">بستن</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>

View file

@ -0,0 +1,25 @@
<template>
<v-app>
<!-- Main Content -->
<v-main>
<router-view />
</v-main>
</v-app>
</template>
<script>
export default {
name: 'PublicLayout',
methods: {
goToLogin() {
this.$router.push({ name: 'user_login' })
}
}
}
</script>
<style scoped>
.v-app-bar {
border-bottom: 1px solid #e0e0e0;
}
</style>

File diff suppressed because it is too large Load diff