Resolve merge conflicts in BusinessController.php

This commit is contained in:
Hesabix 2025-08-22 16:21:50 +00:00
commit 1418591120
28 changed files with 2880 additions and 1317 deletions

View file

@ -0,0 +1,53 @@
<?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 Version20250820232952 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 import_workflow_payment CHANGE amount amount NUMERIC(15, 2) NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE storeroom_ticket ADD completed TINYINT(1) DEFAULT NULL, ADD completed_at DATETIME DEFAULT NULL, ADD completed_by_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE storeroom_ticket ADD CONSTRAINT FK_9B4CC0F785ECDE76 FOREIGN KEY (completed_by_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_9B4CC0F785ECDE76 ON storeroom_ticket (completed_by_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 import_workflow_payment CHANGE amount amount NUMERIC(15, 2) DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE storeroom_ticket DROP FOREIGN KEY FK_9B4CC0F785ECDE76
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_9B4CC0F785ECDE76 ON storeroom_ticket
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE storeroom_ticket DROP completed, DROP completed_at, DROP completed_by_id
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 Version20250820233206 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 device_serial VARCHAR(255) DEFAULT NULL, ADD allocated_to_document_type VARCHAR(50) DEFAULT NULL, ADD allocated_by_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial ADD CONSTRAINT FK_1A5DC26F6802B588 FOREIGN KEY (allocated_by_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_1A5DC26F6802B588 ON plug_warranty_serial (allocated_by_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_1A5DC26F6802B588
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_1A5DC26F6802B588 ON plug_warranty_serial
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_warranty_serial DROP device_serial, DROP allocated_to_document_type, DROP allocated_by_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 Version20250820235141 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 DROP device_serial
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 ADD device_serial VARCHAR(255) DEFAULT NULL
SQL);
}
}

View file

@ -8,6 +8,7 @@ use App\Entity\HesabdariRow;
use App\Entity\Log; use App\Entity\Log;
use App\Entity\Permission; use App\Entity\Permission;
use App\Entity\User; use App\Entity\User;
use App\Entity\Year;
use App\Service\Access; use App\Service\Access;
use App\Service\Log as LogService; use App\Service\Log as LogService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -55,6 +56,10 @@ class ApprovalController extends AbstractController
return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این حواله را ندارید']); return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این حواله را ندارید']);
} }
if (!$this->checkDocumentYear($ticket->getDoc(), $business, $entityManager)) {
return $this->json(['success' => false, 'message' => 'حواله مربوط به این سال مالی نیست']);
}
$ticket->setIsPreview(false); $ticket->setIsPreview(false);
$ticket->setIsApproved(true); $ticket->setIsApproved(true);
$ticket->setApprovedBy($user); $ticket->setApprovedBy($user);
@ -117,6 +122,10 @@ class ApprovalController extends AbstractController
return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این حواله را ندارید']); return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این حواله را ندارید']);
} }
if (!$this->checkDocumentYear($ticket->getDoc(), $business, $entityManager)) {
return $this->json(['success' => false, 'message' => 'حواله مربوط به این سال مالی نیست']);
}
$ticket->setIsPreview(true); $ticket->setIsPreview(true);
$ticket->setIsApproved(false); $ticket->setIsApproved(false);
$ticket->setApprovedBy(null); $ticket->setApprovedBy(null);
@ -179,6 +188,10 @@ class ApprovalController extends AbstractController
return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این فاکتور را ندارید']); return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این فاکتور را ندارید']);
} }
if (!$this->checkDocumentYear($document, $business, $entityManager)) {
return $this->json(['success' => false, 'message' => 'فاکتور مربوط به این سال مالی نیست']);
}
$document->setIsPreview(false); $document->setIsPreview(false);
$document->setIsApproved(true); $document->setIsApproved(true);
$document->setApprovedBy($user); $document->setApprovedBy($user);
@ -262,6 +275,10 @@ class ApprovalController extends AbstractController
return $this->json(['success' => false, 'message' => 'فاکتور فروش تایید شده است']); return $this->json(['success' => false, 'message' => 'فاکتور فروش تایید شده است']);
} }
if (!$this->checkDocumentYear($document, $business, $entityManager)) {
return $this->json(['success' => false, 'message' => 'فاکتور مربوط به این سال مالی نیست']);
}
$document->setIsPreview(false); $document->setIsPreview(false);
$document->setIsApproved(true); $document->setIsApproved(true);
$document->setApprovedBy($user); $document->setApprovedBy($user);
@ -338,6 +355,10 @@ class ApprovalController extends AbstractController
return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این فاکتور را ندارید']); return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این فاکتور را ندارید']);
} }
if (!$this->checkDocumentYear($document, $business, $entityManager)) {
return $this->json(['success' => false, 'message' => 'فاکتور مربوط به این سال مالی نیست']);
}
$document->setIsPreview(true); $document->setIsPreview(true);
$document->setIsApproved(false); $document->setIsApproved(false);
$document->setApprovedBy(null); $document->setApprovedBy(null);
@ -413,6 +434,10 @@ class ApprovalController extends AbstractController
return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این فاکتور را ندارید']); return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این فاکتور را ندارید']);
} }
if (!$this->checkDocumentYear($document, $business, $entityManager)) {
return $this->json(['success' => false, 'message' => 'فاکتور خرید مربوط به این سال مالی نیست']);
}
$document->setIsPreview(false); $document->setIsPreview(false);
$document->setIsApproved(true); $document->setIsApproved(true);
$document->setApprovedBy($user); $document->setApprovedBy($user);
@ -483,6 +508,10 @@ class ApprovalController extends AbstractController
return $this->json(['success' => false, 'message' => 'فاکتور خرید تایید شده است']); return $this->json(['success' => false, 'message' => 'فاکتور خرید تایید شده است']);
} }
if (!$this->checkDocumentYear($document, $business, $entityManager)) {
return $this->json(['success' => false, 'message' => 'فاکتور خرید مربوط به این سال مالی نیست']);
}
$document->setIsPreview(false); $document->setIsPreview(false);
$document->setIsApproved(true); $document->setIsApproved(true);
$document->setApprovedBy($user); $document->setApprovedBy($user);
@ -547,6 +576,10 @@ class ApprovalController extends AbstractController
return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این فاکتور را ندارید']); return $this->json(['success' => false, 'message' => 'شما مجوز تأیید این فاکتور را ندارید']);
} }
if (!$this->checkDocumentYear($document, $business, $entityManager)) {
return $this->json(['success' => false, 'message' => 'فاکتور خرید مربوط به این سال مالی نیست']);
}
$document->setIsPreview(true); $document->setIsPreview(true);
$document->setIsApproved(false); $document->setIsApproved(false);
$document->setApprovedBy(null); $document->setApprovedBy(null);
@ -617,6 +650,10 @@ class ApprovalController extends AbstractController
return $this->json(['success' => false, 'message' => 'فاکتور خرید تایید نشده است']); return $this->json(['success' => false, 'message' => 'فاکتور خرید تایید نشده است']);
} }
if (!$this->checkDocumentYear($document, $business, $entityManager)) {
return $this->json(['success' => false, 'message' => 'فاکتور خرید مربوط به این سال مالی نیست']);
}
$document->setIsPreview(true); $document->setIsPreview(true);
$document->setIsApproved(false); $document->setIsApproved(false);
$document->setApprovedBy(null); $document->setApprovedBy(null);
@ -646,6 +683,15 @@ class ApprovalController extends AbstractController
} }
} }
private function checkDocumentYear(HesabdariDoc $document, Business $business, EntityManagerInterface $entityManager): bool
{
$year = $entityManager->getRepository(Year::class)->findOneBy([
'bid' => $business,
'head' => true
]);
return $document->getYear()->getId() == $year->getId();
}
private function canUserApproveDocument(User $user, Business $business, string $documentType): bool private function canUserApproveDocument(User $user, Business $business, string $documentType): bool
{ {
if ($user->getEmail() === $business->getOwner()->getEmail()) { if ($user->getEmail() === $business->getOwner()->getEmail()) {

View file

@ -583,11 +583,13 @@ class BusinessController extends AbstractController
'plugGhestaManager' => true, 'plugGhestaManager' => true,
'plugTaxSettings' => true, 'plugTaxSettings' => true,
'plugWarranty' => true, 'plugWarranty' => true,
'plugImportWorkflow' => true,
'inquiry' => true, 'inquiry' => true,
'ai' => true, 'ai' => true,
'warehouseManager' => true, 'warehouseManager' => true,
'importWorkflow' => true, 'importWorkflow' => true,
'plugHrmAttendance' => true, 'plugHrmAttendance' => true,
'storehelper' => true,
]; ];
} elseif ($perm) { } elseif ($perm) {
$result = [ $result = [
@ -633,19 +635,14 @@ class BusinessController extends AbstractController
'plugGhestaManager' => $perm->isPlugGhestaManager(), 'plugGhestaManager' => $perm->isPlugGhestaManager(),
'plugTaxSettings' => $perm->isPlugTaxSettings(), 'plugTaxSettings' => $perm->isPlugTaxSettings(),
'plugWarranty' => $perm->isPlugWarrantyManager(), 'plugWarranty' => $perm->isPlugWarrantyManager(),
'plugImportWorkflow' => $perm->isImportWorkflow(),
'inquiry' => $perm->isInquiry(), 'inquiry' => $perm->isInquiry(),
'ai' => $perm->isAi(), 'ai' => $perm->isAi(),
'warehouseManager' => $perm->isWarehouseManager(), 'warehouseManager' => $perm->isWarehouseManager(),
'importWorkflow' => $perm->isImportWorkflow(), 'importWorkflow' => $perm->isImportWorkflow(),
'plugHrmAttendance' => $perm->isPlugHrmAttendance(), 'plugHrmAttendance' => $perm->isPlugHrmAttendance(),
'storehelper' => $perm->isStorehelper()
]; ];
if ($perm->isWarehouseManager()) {
$result['commodity'] = true;
$result['store'] = true;
$result['plugWarranty'] = true;
$result['permission'] = true;
}
} }
return $this->json($result); return $this->json($result);
} }
@ -716,11 +713,13 @@ class BusinessController extends AbstractController
$perm->setPlugGhestaManager($params['plugGhestaManager']); $perm->setPlugGhestaManager($params['plugGhestaManager']);
$perm->setPlugWarrantyManager($params['plugWarranty'] ?? false); $perm->setPlugWarrantyManager($params['plugWarranty'] ?? false);
$perm->setPlugTaxSettings($params['plugTaxSettings']); $perm->setPlugTaxSettings($params['plugTaxSettings']);
$perm->setImportWorkflow($params['plugImportWorkflow'] ?? false);
$perm->setInquiry($params['inquiry']); $perm->setInquiry($params['inquiry']);
$perm->setAi($params['ai']); $perm->setAi($params['ai']);
$perm->setWarehouseManager($params['warehouseManager'] ?? false); $perm->setWarehouseManager($params['warehouseManager'] ?? false);
$perm->setImportWorkflow($params['importWorkflow'] ?? false); $perm->setImportWorkflow($params['importWorkflow'] ?? false);
$perm->setPlugHrmAttendance($params['plugHrmAttendance'] ?? false); $perm->setPlugHrmAttendance($params['plugHrmAttendance'] ?? false);
$perm->setStorehelper($params['storehelper'] ?? false);
$entityManager->persist($perm); $entityManager->persist($perm);
$entityManager->flush(); $entityManager->flush();
$log->insert('تنظیمات پایه', 'ویرایش دسترسی‌های کاربر با پست الکترونیکی ' . $user->getEmail(), $this->getUser(), $business); $log->insert('تنظیمات پایه', 'ویرایش دسترسی‌های کاربر با پست الکترونیکی ' . $user->getEmail(), $this->getUser(), $business);

View file

@ -71,9 +71,9 @@ class PlugWarrantyController extends AbstractController
$serials = $entityManager->getRepository(PlugWarrantySerial::class)->createQueryBuilder('s') $serials = $entityManager->getRepository(PlugWarrantySerial::class)->createQueryBuilder('s')
->andWhere('s.business = :bid') ->andWhere('s.business = :bid')
->andWhere('s.allocatedToDocumentId = :docId') ->andWhere('s.activationTicketCode = :code')
->setParameter('bid', $acc['bid']) ->setParameter('bid', $acc['bid'])
->setParameter('docId', $doc->getId()) ->setParameter('code', $code)
->getQuery() ->getQuery()
->getResult(); ->getResult();
@ -81,6 +81,7 @@ class PlugWarrantyController extends AbstractController
$commodity = $s->getCommodity(); $commodity = $s->getCommodity();
return [ return [
'serialNumber' => $s->getSerialNumber(), 'serialNumber' => $s->getSerialNumber(),
'commoditySerial' => $s->getCommoditySerial(),
'commodity' => $commodity ? [ 'commodity' => $commodity ? [
'id' => $commodity->getId(), 'id' => $commodity->getId(),
'name' => $commodity->getName(), 'name' => $commodity->getName(),

View file

@ -60,16 +60,18 @@ class StoreroomController extends AbstractController
public function uploadTicketAttachment(string $code, Request $request, Access $access, EntityManagerInterface $entityManager, \App\Service\FileStorage $storage): JsonResponse public function uploadTicketAttachment(string $code, Request $request, Access $access, EntityManagerInterface $entityManager, \App\Service\FileStorage $storage): JsonResponse
{ {
$acc = $access->hasRole('store'); $acc = $access->hasRole('store');
if (!$acc) throw $this->createAccessDeniedException(); if (!$acc)
$ticket = $entityManager->getRepository(StoreroomTicket::class)->findOneBy(['bid'=>$acc['bid'],'code'=>$code]); throw $this->createAccessDeniedException();
if (!$ticket) throw $this->createNotFoundException('حواله یافت نشد'); $ticket = $entityManager->getRepository(StoreroomTicket::class)->findOneBy(['bid' => $acc['bid'], 'code' => $code]);
if (!$ticket)
throw $this->createNotFoundException('حواله یافت نشد');
$file = $request->files->get('file'); $file = $request->files->get('file');
if (!$file) { if (!$file) {
return $this->json(['result'=>-1,'message'=>'فایل ارسال نشده است'], 400); return $this->json(['result' => -1, 'message' => 'فایل ارسال نشده است'], 400);
} }
$stored = $storage->store($file, (string)$acc['bid']->getId(), 'storeroom_attachments'); $stored = $storage->store($file, (string) $acc['bid']->getId(), 'storeroom_attachments');
$archive = new ArchiveFile(); $archive = new ArchiveFile();
$archive->setBid($acc['bid']); $archive->setBid($acc['bid']);
@ -82,33 +84,35 @@ class StoreroomController extends AbstractController
$archive->setDes($request->request->get('des')); $archive->setDes($request->request->get('des'));
$archive->setRelatedDocType('storeroom_ticket'); $archive->setRelatedDocType('storeroom_ticket');
$archive->setRelatedDocCode($ticket->getCode()); $archive->setRelatedDocCode($ticket->getCode());
$archive->setFileSize($stored['size'] !== null ? (string)$stored['size'] : null); $archive->setFileSize($stored['size'] !== null ? (string) $stored['size'] : null);
$entityManager->persist($archive); $entityManager->persist($archive);
$entityManager->flush(); $entityManager->flush();
return $this->json(['result'=>0]); return $this->json(['result' => 0]);
} }
#[Route('/api/storeroom/ticket/attachments/{code}', name: 'app_storeroom_ticket_list_attachments', methods: ['GET'])] #[Route('/api/storeroom/ticket/attachments/{code}', name: 'app_storeroom_ticket_list_attachments', methods: ['GET'])]
public function listTicketAttachments(string $code, Access $access, EntityManagerInterface $entityManager): JsonResponse public function listTicketAttachments(string $code, Access $access, EntityManagerInterface $entityManager): JsonResponse
{ {
$acc = $access->hasRole('store'); $acc = $access->hasRole('store');
if (!$acc) throw $this->createAccessDeniedException(); if (!$acc)
$ticket = $entityManager->getRepository(StoreroomTicket::class)->findOneBy(['bid'=>$acc['bid'],'code'=>$code]); throw $this->createAccessDeniedException();
if (!$ticket) throw $this->createNotFoundException('حواله یافت نشد'); $ticket = $entityManager->getRepository(StoreroomTicket::class)->findOneBy(['bid' => $acc['bid'], 'code' => $code]);
if (!$ticket)
throw $this->createNotFoundException('حواله یافت نشد');
$items = $entityManager->getRepository(ArchiveFile::class)->findBy([ $items = $entityManager->getRepository(ArchiveFile::class)->findBy([
'bid'=>$acc['bid'], 'bid' => $acc['bid'],
'relatedDocType'=>'storeroom_ticket', 'relatedDocType' => 'storeroom_ticket',
'relatedDocCode'=>$ticket->getCode() 'relatedDocCode' => $ticket->getCode()
], ['id'=>'DESC']); ], ['id' => 'DESC']);
return $this->json(array_map(function(ArchiveFile $a){ return $this->json(array_map(function (ArchiveFile $a) {
return [ return [
'id'=>$a->getId(), 'id' => $a->getId(),
'filename'=>$a->getFilename(), 'filename' => $a->getFilename(),
'fileType'=>$a->getFileType(), 'fileType' => $a->getFileType(),
'fileSize'=>$a->getFileSize(), 'fileSize' => $a->getFileSize(),
'des'=>$a->getDes(), 'des' => $a->getDes(),
'dateSubmit'=>$a->getDateSubmit(), 'dateSubmit' => $a->getDateSubmit(),
]; ];
}, $items)); }, $items));
} }
@ -117,12 +121,13 @@ class StoreroomController extends AbstractController
public function downloadTicketAttachment(int $id, Access $access, EntityManagerInterface $entityManager, \App\Service\FileStorage $storage): Response public function downloadTicketAttachment(int $id, Access $access, EntityManagerInterface $entityManager, \App\Service\FileStorage $storage): Response
{ {
$acc = $access->hasRole('store'); $acc = $access->hasRole('store');
if (!$acc) throw $this->createAccessDeniedException(); if (!$acc)
throw $this->createAccessDeniedException();
$a = $entityManager->getRepository(ArchiveFile::class)->find($id); $a = $entityManager->getRepository(ArchiveFile::class)->find($id);
if (!$a || $a->getBid()->getId() !== $acc['bid']->getId()) { if (!$a || $a->getBid()->getId() !== $acc['bid']->getId()) {
throw $this->createNotFoundException('فایل یافت نشد'); throw $this->createNotFoundException('فایل یافت نشد');
} }
$abs = $storage->absolutePath((string)$a->getFilename()); $abs = $storage->absolutePath((string) $a->getFilename());
if (!is_file($abs) || !is_readable($abs)) { if (!is_file($abs) || !is_readable($abs)) {
throw $this->createNotFoundException('فایل موجود نیست'); throw $this->createNotFoundException('فایل موجود نیست');
} }
@ -201,7 +206,7 @@ class StoreroomController extends AbstractController
* @throws ReflectionException * @throws ReflectionException
*/ */
#[Route('/api/storeroom/docs/get', name: 'app_storeroom_get_docs')] #[Route('/api/storeroom/docs/get', name: 'app_storeroom_get_docs')]
public function app_storeroom_get_docs(Provider $provider,Extractor $extractor, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager): JsonResponse public function app_storeroom_get_docs(Provider $provider, Extractor $extractor, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager): JsonResponse
{ {
$acc = $access->hasRole('store'); $acc = $access->hasRole('store');
if (!$acc) if (!$acc)
@ -466,10 +471,8 @@ class StoreroomController extends AbstractController
if ($content = $request->getContent()) { if ($content = $request->getContent()) {
$params = json_decode($content, true); $params = json_decode($content, true);
} }
//check parameters exist
if ((!array_key_exists('ticket', $params)) || (!array_key_exists('items', $params)) || (!array_key_exists('doc', $params))) if ((!array_key_exists('ticket', $params)) || (!array_key_exists('items', $params)) || (!array_key_exists('doc', $params)))
$this->createNotFoundException(); $this->createNotFoundException();
//going to save
$doc = $entityManager->getRepository(HesabdariDoc::class)->findOneBy([ $doc = $entityManager->getRepository(HesabdariDoc::class)->findOneBy([
'id' => $params['doc']['id'], 'id' => $params['doc']['id'],
'bid' => $acc['bid'], 'bid' => $acc['bid'],
@ -479,14 +482,11 @@ class StoreroomController extends AbstractController
throw $this->createNotFoundException('سند یافت نشد'); throw $this->createNotFoundException('سند یافت نشد');
if ($doc->getBid()->getId() != $acc['bid']->getId()) if ($doc->getBid()->getId() != $acc['bid']->getId())
throw $this->createAccessDeniedException('دسترسی به این سند را ندارید.'); throw $this->createAccessDeniedException('دسترسی به این سند را ندارید.');
//find transfer type
if (!array_key_exists('transferType', $params['ticket'])) if (!array_key_exists('transferType', $params['ticket']))
throw $this->createNotFoundException('نوع انتقال یافت نشد'); throw $this->createNotFoundException('نوع انتقال یافت نشد');
$transferType = $entityManager->getRepository(StoreroomTransferType::class)->find($params['ticket']['transferType']['id']); $transferType = $entityManager->getRepository(StoreroomTransferType::class)->find($params['ticket']['transferType']['id']);
if (!$transferType) if (!$transferType)
throw $this->createNotFoundException('نوع انتقال یافت نشد'); throw $this->createNotFoundException('نوع انتقال یافت نشد');
//find storeroom
if (!array_key_exists('store', $params['ticket'])) if (!array_key_exists('store', $params['ticket']))
throw $this->createNotFoundException('انبار یافت نشد'); throw $this->createNotFoundException('انبار یافت نشد');
$storeroom = $entityManager->getRepository(Storeroom::class)->find($params['ticket']['store']['id']); $storeroom = $entityManager->getRepository(Storeroom::class)->find($params['ticket']['store']['id']);
@ -494,7 +494,6 @@ class StoreroomController extends AbstractController
throw $this->createNotFoundException('انبار یافت نشد'); throw $this->createNotFoundException('انبار یافت نشد');
elseif ($storeroom->getBid()->getId() != $acc['bid']->getId()) elseif ($storeroom->getBid()->getId() != $acc['bid']->getId())
throw $this->createAccessDeniedException('دسترسی به این انبار ممکن نیست!'); throw $this->createAccessDeniedException('دسترسی به این انبار ممکن نیست!');
//find person
if (!array_key_exists('person', $params['ticket'])) if (!array_key_exists('person', $params['ticket']))
throw $this->createNotFoundException('طرف حساب یافت نشد'); throw $this->createNotFoundException('طرف حساب یافت نشد');
$person = $entityManager->getRepository(Person::class)->find($params['ticket']['person']['id']); $person = $entityManager->getRepository(Person::class)->find($params['ticket']['person']['id']);
@ -502,7 +501,6 @@ class StoreroomController extends AbstractController
throw $this->createNotFoundException('طرف حساب یافت نشد'); throw $this->createNotFoundException('طرف حساب یافت نشد');
elseif ($person->getBid()->getId() != $acc['bid']->getId()) elseif ($person->getBid()->getId() != $acc['bid']->getId())
throw $this->createAccessDeniedException('دسترسی به این طرف حساب ممکن نیست!'); throw $this->createAccessDeniedException('دسترسی به این طرف حساب ممکن نیست!');
$ticket = new StoreroomTicket(); $ticket = new StoreroomTicket();
$ticket->setSubmitter($this->getUser()); $ticket->setSubmitter($this->getUser());
$ticket->setDate($params['ticket']['date']); $ticket->setDate($params['ticket']['date']);
@ -515,7 +513,9 @@ class StoreroomController extends AbstractController
$ticket->setCode($provider->getAccountingCode($acc['bid'], 'storeroom')); $ticket->setCode($provider->getAccountingCode($acc['bid'], 'storeroom'));
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
$rand = ''; $rand = '';
for ($i = 0; $i < 8; $i++) { $rand .= $alphabet[random_int(0, strlen($alphabet)-1)]; } for ($i = 0; $i < 8; $i++) {
$rand .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
$ticket->setActivationCode($rand); $ticket->setActivationCode($rand);
$ticket->setReceiver($params['ticket']['receiver']); $ticket->setReceiver($params['ticket']['receiver']);
$ticket->setTransferType($transferType); $ticket->setTransferType($transferType);
@ -529,32 +529,25 @@ class StoreroomController extends AbstractController
$ticket->setImportWorkflowCode($params['ticket']['importWorkflowCode']); $ticket->setImportWorkflowCode($params['ticket']['importWorkflowCode']);
} }
$entityManager->persist($ticket); $entityManager->persist($ticket);
//$entityManager->flush();
//going to save rows
$docRows = $entityManager->getRepository(HesabdariRow::class)->findBy([ $docRows = $entityManager->getRepository(HesabdariRow::class)->findBy([
'doc' => $doc 'doc' => $doc
]); ]);
$requireWarrantySerial = (
// Determine if warranty serials are required based on flag or provided lines isset($params['ticket']['requireWarrantySerial'])
$hasSerialLines = false; && $params['ticket']['requireWarrantySerial'] === true
foreach (($params['items'] ?? []) as $it) { && $pluginService->isActive('warranty', $acc['bid'])
if (!empty($it['serialLines']) && is_array($it['serialLines'])) { $hasSerialLines = true; break; } );
}
$requireWarrantySerial = (isset($params['ticket']['requireWarrantySerial']) && $params['ticket']['requireWarrantySerial'] === true) || $hasSerialLines;
if ($requireWarrantySerial) { if ($requireWarrantySerial) {
if (!$pluginService->isActive('warranty', $acc['bid'])) {
return $this->json(['result' => -5, 'message' => 'افزونه گارانتی فعال نیست'], 403);
}
// Validate counts up-front
foreach ($params['items'] as $item) { foreach ($params['items'] as $item) {
$lines = isset($item['serialLines']) && is_array($item['serialLines']) ? $item['serialLines'] : []; $lines = isset($item['serialLines']) && is_array($item['serialLines']) ? $item['serialLines'] : [];
if ((int)($item['ticketCount'] ?? 0) > 0 && count($lines) < (int)$item['ticketCount']) { if ((int) ($item['ticketCount'] ?? 0) > 0 && count($lines) < (int) $item['ticketCount']) {
return $this->json(['result' => -3, 'message' => 'تعداد سریال/گارانتی با تعداد حواله همخوانی ندارد'], 400); return $this->json([
'result' => -3,
'message' => 'تعداد سریال/گارانتی با تعداد حواله همخوانی ندارد'
], 400);
} }
} }
} }
foreach ($params['items'] as $item) { foreach ($params['items'] as $item) {
$row = $entityManager->getRepository(HesabdariRow::class)->findOneBy([ $row = $entityManager->getRepository(HesabdariRow::class)->findOneBy([
'bid' => $acc['bid'], 'bid' => $acc['bid'],
@ -565,7 +558,6 @@ class StoreroomController extends AbstractController
throw $this->createNotFoundException('کالا یافت نشد!'); throw $this->createNotFoundException('کالا یافت نشد!');
if (!$row->getCommodity()) if (!$row->getCommodity())
throw $this->createNotFoundException('کالا یافت نشد!'); throw $this->createNotFoundException('کالا یافت نشد!');
//check row count not upper ticket count
if ($row->getCommdityCount() < $item['ticketCount']) if ($row->getCommdityCount() < $item['ticketCount'])
throw $this->createNotFoundException('تعداد کالای اضافه شده بیشتر از تعداد کالا در فاکتور است.'); throw $this->createNotFoundException('تعداد کالای اضافه شده بیشتر از تعداد کالا در فاکتور است.');
$ticketItem = new StoreroomItem(); $ticketItem = new StoreroomItem();
@ -578,21 +570,17 @@ class StoreroomController extends AbstractController
$ticketItem->setCommodity($row->getCommodity()); $ticketItem->setCommodity($row->getCommodity());
$ticketItem->setType($item['type']); $ticketItem->setType($item['type']);
$entityManager->persist($ticketItem); $entityManager->persist($ticketItem);
$lines = isset($item['serialLines']) && is_array($item['serialLines']) ? $item['serialLines'] : [];
// Bind warranty serials per item if provided
if ($requireWarrantySerial) { if ($requireWarrantySerial) {
$lines = isset($item['serialLines']) && is_array($item['serialLines']) ? $item['serialLines'] : []; if ((int) $item['ticketCount'] > 0) {
if ((int)$item['ticketCount'] > 0) {
// Ensure we have an id to bind to
$entityManager->flush(); $entityManager->flush();
$lines = array_slice($lines, 0, (int)$item['ticketCount']); $lines = array_slice($lines, 0, (int) $item['ticketCount']);
foreach ($lines as $ln) { foreach ($lines as $ln) {
$warrantyCode = $ln['warranty'] ?? null; $warrantyCode = $ln['warranty'] ?? null;
$deviceSerial = $ln['serial'] ?? null; $deviceSerial = $ln['serial'] ?? null;
if (!$warrantyCode) { if (!$warrantyCode) {
return $this->json(['result' => -4, 'message' => 'کد گارانتی ارسال نشده است'], 400); return $this->json(['result' => -4, 'message' => 'کد گارانتی ارسال نشده است'], 400);
} }
/** @var PlugWarrantySerial|null $serial */
$serial = $entityManager->getRepository(PlugWarrantySerial::class)->findOneBy([ $serial = $entityManager->getRepository(PlugWarrantySerial::class)->findOneBy([
'business' => $acc['bid'], 'business' => $acc['bid'],
'serialNumber' => $warrantyCode, 'serialNumber' => $warrantyCode,
@ -613,27 +601,51 @@ class StoreroomController extends AbstractController
$entityManager->persist($serial); $entityManager->persist($serial);
} }
} }
} else {
if (!empty($lines)) {
$entityManager->flush();
foreach ($lines as $ln) {
$warrantyCode = $ln['warranty'] ?? null;
$deviceSerial = $ln['serial'] ?? null;
if (!$warrantyCode) {
continue;
}
$serial = $entityManager->getRepository(PlugWarrantySerial::class)->findOneBy([
'business' => $acc['bid'],
'serialNumber' => $warrantyCode,
'commodity' => $row->getCommodity(),
]);
if (!$serial || $serial->getStatus() !== PlugWarrantySerial::STATUS_AVAILABLE) {
continue;
}
$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(); $entityManager->flush();
$business = $entityManager->getRepository(\App\Entity\Business::class)->find($acc['bid']); $business = $entityManager->getRepository(\App\Entity\Business::class)->find($acc['bid']);
$businessRequire = $business && method_exists($business, 'isRequireTwoStepApproval') ? (bool)$business->isRequireTwoStepApproval() : false; $businessRequire = $business && method_exists($business, 'isRequireTwoStepApproval') ? (bool) $business->isRequireTwoStepApproval() : false;
if ($businessRequire) { if ($businessRequire) {
$ticket->setIsPreview(true); $ticket->setIsPreview(true);
$ticket->setIsApproved(false); $ticket->setIsApproved(false);
$ticket->setApprovedBy(null); // هنوز تأیید نشده $ticket->setApprovedBy(null);
} else { } else {
$ticket->setIsPreview(false); $ticket->setIsPreview(false);
$ticket->setIsApproved(true); $ticket->setIsApproved(true);
$ticket->setApprovedBy($this->getUser()); // تأیید شده توسط کاربر فعلی $ticket->setApprovedBy($this->getUser());
} }
//save logs
$log->insert('انبارداری', 'حواله انبار با شماره ' . $ticket->getCode() . ' اضافه / ویرایش شد.', $this->getUser(), $acc['bid']); $log->insert('انبارداری', 'حواله انبار با شماره ' . $ticket->getCode() . ' اضافه / ویرایش شد.', $this->getUser(), $acc['bid']);
if ($pluginService->isActive('accpro', $acc['bid'])) { if ($pluginService->isActive('accpro', $acc['bid'])) {
//notification to person
if ($params['ticket']['sms'] == true) { if ($params['ticket']['sms'] == true) {
$ticket->setCanShare(true); $ticket->setCanShare(true);
$entityManager->persist($ticket); $entityManager->persist($ticket);
@ -676,7 +688,6 @@ class StoreroomController extends AbstractController
3 3
); );
} }
if ($smsres == 2) { if ($smsres == 2) {
return $this->json([ return $this->json([
'result' => 2 'result' => 2
@ -684,7 +695,6 @@ class StoreroomController extends AbstractController
} }
} }
} }
return $this->json([ return $this->json([
'result' => 0 'result' => 0
]); ]);
@ -698,7 +708,7 @@ class StoreroomController extends AbstractController
throw $this->createAccessDeniedException(); throw $this->createAccessDeniedException();
$params = json_decode($request->getContent() ?: '{}', true); $params = json_decode($request->getContent() ?: '{}', true);
$status = $params['status'] ?? null; // in_progress|done|rejected|approved|pending_approval $status = $params['status'] ?? null; // in_progress|done|rejected|approved|pending_approval
if (!in_array($status, ['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); return $this->json(['result' => -1, 'message' => 'وضعیت نامعتبر'], 400);
} }
$ticket = $entityManager->getRepository(StoreroomTicket::class)->findOneBy([ $ticket = $entityManager->getRepository(StoreroomTicket::class)->findOneBy([
@ -729,7 +739,7 @@ class StoreroomController extends AbstractController
$criteria['status'] = $status; $criteria['status'] = $status;
} }
$tickets = $entityManager->getRepository(StoreroomTicket::class)->findBy($criteria, ['date' => 'DESC']); $tickets = $entityManager->getRepository(StoreroomTicket::class)->findBy($criteria, ['date' => 'DESC']);
return $this->json(array_map(function(StoreroomTicket $t){ return $this->json(array_map(function (StoreroomTicket $t) {
return [ return [
'code' => $t->getCode(), 'code' => $t->getCode(),
'date' => $t->getDate(), 'date' => $t->getDate(),
@ -764,7 +774,8 @@ class StoreroomController extends AbstractController
'getDoc', 'getDoc',
'getTypeString', 'getTypeString',
'isPreview', 'isPreview',
'isApproved' 'isApproved',
'isCompleted'
], 2); ], 2);
foreach ($result as $key => &$ticket) { foreach ($result as $key => &$ticket) {
@ -779,6 +790,16 @@ class StoreroomController extends AbstractController
} else { } else {
$ticket['approvedBy'] = null; $ticket['approvedBy'] = null;
} }
if ($ticketEntity->getCompletedBy()) {
$completedBy = $ticketEntity->getCompletedBy();
$ticket['completedBy'] = [
'id' => $completedBy->getId(),
'fullName' => $completedBy->getFullName(),
'email' => $completedBy->getEmail()
];
} else {
$ticket['completedBy'] = null;
}
} }
return $this->json($result); return $this->json($result);
@ -799,7 +820,7 @@ class StoreroomController extends AbstractController
//get items //get items
$items = $entityManager->getRepository(StoreroomItem::class)->findBy(['ticket' => $ticket]); $items = $entityManager->getRepository(StoreroomItem::class)->findBy(['ticket' => $ticket]);
$res = []; $res = [];
$res['ticket'] = $provider->Entity2ArrayJustIncludes($ticket, ['getStoreroom', 'getManager', 'getDate', 'getSubmitDate', 'getDes', 'getReceiver', 'getTransfer', 'getCode', 'getType', 'getReferral', 'getTypeString', 'isPreview', 'isApproved'], 2); $res['ticket'] = $provider->Entity2ArrayJustIncludes($ticket, ['getStoreroom', 'getManager', 'getDate', 'getSubmitDate', 'getDes', 'getReceiver', 'getTransfer', 'getCode', 'getType', 'getReferral', 'getTypeString', 'isPreview', 'isApproved', 'isCompleted'], 2);
$res['transferType'] = $provider->Entity2ArrayJustIncludes($ticket->getTransferType(), ['getName'], 0); $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); $res['person'] = $provider->Entity2ArrayJustIncludes($ticket->getPerson(), ['getKeshvar', 'getOstan', 'getShahr', 'getAddress', 'getNikename', 'getCodeeghtesadi', 'getPostalcode', 'getName', 'getTel', 'getSabt'], 0);
//get rows //get rows
@ -916,9 +937,9 @@ class StoreroomController extends AbstractController
} else { } else {
$title = 'حواله خروج از انبار'; $title = 'حواله خروج از انبار';
} }
$business = $entityManager->getRepository(\App\Entity\Business::class)->find($acc['bid']); $business = $entityManager->getRepository(\App\Entity\Business::class)->find($acc['bid']);
$businessRequire = $business && method_exists($business, 'isRequireTwoStepApproval') ? (bool)$business->isRequireTwoStepApproval() : false; $businessRequire = $business && method_exists($business, 'isRequireTwoStepApproval') ? (bool) $business->isRequireTwoStepApproval() : false;
if ($businessRequire && $doc->isApproved() !== true && $doc->isPreview() == true) { if ($businessRequire && $doc->isApproved() !== true && $doc->isPreview() == true) {
if ($doc->isPreview()) { if ($doc->isPreview()) {
return $this->json(['result' => -10, 'message' => 'حواله هنوز تایید نشده است'], 403); return $this->json(['result' => -10, 'message' => 'حواله هنوز تایید نشده است'], 403);
@ -973,4 +994,103 @@ class StoreroomController extends AbstractController
); );
return $this->json(['id' => $pdfPid]); return $this->json(['id' => $pdfPid]);
} }
#[Route('/api/storeroom/ticket/complete/{id}', name: 'app_storeroom_ticket_complete', methods: ['POST'])]
public function app_storeroom_ticket_complete(string $id, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager, PluginService $pluginService): JsonResponse
{
$acc = $access->hasRole('store');
if (!$acc)
throw $this->createAccessDeniedException();
$params = [];
if ($content = $request->getContent()) {
$params = json_decode($content, true);
}
$ticket = $entityManager->getRepository(StoreroomTicket::class)->findOneBy([
'code' => $id,
'bid' => $acc['bid']
]);
if (!$ticket)
throw $this->createNotFoundException('حواله یافت نشد');
$requireWarrantySerial = (
isset($params['requireWarrantySerial'])
&& $params['requireWarrantySerial'] === true
&& $pluginService->isActive('warranty', $acc['bid'])
);
if ($pluginService->isActive('warranty', $acc['bid'])) {
$warrantyAllocations = $params['warrantyAllocations'] ?? [];
foreach ($warrantyAllocations as $allocation) {
$commodityId = $allocation['commodityId'] ?? null;
$warrantyLines = $allocation['warrantyLines'] ?? [];
if (!$commodityId || empty($warrantyLines)) {
if ($requireWarrantySerial) {
return $this->json(['result' => -3, 'message' => 'سریال گارانتی برای کالا ارسال نشده است'], 400);
}
continue;
}
$commodity = $entityManager->getRepository(Commodity::class)->find($commodityId);
if (!$commodity) {
if ($requireWarrantySerial) {
return $this->json(['result' => -3, 'message' => 'کالا معتبر نیست برای گارانتی'], 400);
}
continue;
}
foreach ($warrantyLines as $line) {
$warrantySerial = $line['warrantySerial'] ?? null;
$deviceSerial = $line['serialNumber'] ?? null;
$isBeforeAllocated = $line['isBeforeAllocated'] ?? false;
if (!$warrantySerial) {
if ($requireWarrantySerial) {
return $this->json(['result' => -4, 'message' => 'کد گارانتی ارسال نشده است'], 400);
}
continue;
}
if ($isBeforeAllocated) {
continue;
}
$warrantySerialEntity = $entityManager->getRepository(PlugWarrantySerial::class)->findOneBy([
'business' => $acc['bid'],
'serialNumber' => $warrantySerial,
'commodity' => $commodity,
'status' => PlugWarrantySerial::STATUS_AVAILABLE
]);
if (!$warrantySerialEntity) {
if ($requireWarrantySerial) {
return $this->json(['result' => -2, 'message' => "گارانتی {$warrantySerial} یافت نشد یا در دسترس نیست"], 400);
}
continue;
}
$warrantySerialEntity->setStatus(PlugWarrantySerial::STATUS_CONSUMED);
$warrantySerialEntity->setAllocatedToDocumentId($ticket->getId());
$warrantySerialEntity->setActivationTicketCode($ticket->getCode());
$warrantySerialEntity->setActivationTicketSecret($ticket->getActivationCode());
$warrantySerialEntity->setAllocatedToDocumentType('storeroom_ticket');
$warrantySerialEntity->setAllocatedAt(new \DateTimeImmutable());
$warrantySerialEntity->setAllocatedBy($this->getUser());
if ($deviceSerial) {
$warrantySerialEntity->setCommoditySerial($deviceSerial);
}
$entityManager->persist($warrantySerialEntity);
}
}
}
$ticket->setCompleted(true);
$ticket->setCompletedAt(new \DateTimeImmutable());
$ticket->setCompletedBy($this->getUser());
$entityManager->persist($ticket);
$entityManager->flush();
$log->insert('انبارداری', 'پروسه حواله انبار با شماره ' . $ticket->getCode() . ' تکمیل شد.', $this->getUser(), $acc['bid']);
return $this->json([
'result' => 0,
'message' => 'پروسه با موفقیت تکمیل شد'
]);
}
} }

View file

@ -409,5 +409,15 @@ class ImportWorkflow
} }
return $this; return $this;
} }
public function getComputedTotalAmount(): ?string
{
$items = $this->getItems();
$total = 0;
foreach ($items as $item) {
$total += $item->getTotalPrice();
}
return $total;
}
} }

View file

@ -142,7 +142,7 @@ class Permission
private ?bool $ai = null; private ?bool $ai = null;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?bool $warehouseManager = null; private ?bool $storehelper = null;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?bool $importWorkflow = null; private ?bool $importWorkflow = null;
@ -659,14 +659,14 @@ class Permission
return $this; return $this;
} }
public function isWarehouseManager(): ?bool public function isStorehelper(): ?bool
{ {
return $this->warehouseManager; return $this->storehelper;
} }
public function setWarehouseManager(?bool $warehouseManager): static public function setStorehelper(?bool $storehelper): static
{ {
$this->warehouseManager = $warehouseManager; $this->storehelper = $storehelper;
return $this; return $this;
} }

View file

@ -82,6 +82,12 @@ class PlugWarrantySerial
#[ORM\ManyToOne] #[ORM\ManyToOne]
private ?Person $buyer = null; private ?Person $buyer = null;
#[ORM\Column(type: 'string', length: 50, nullable: true)]
private ?string $allocatedToDocumentType = null;
#[ORM\ManyToOne]
private ?User $allocatedBy = null;
#[ORM\Column(type: 'string', length: 32, nullable: true)] #[ORM\Column(type: 'string', length: 32, nullable: true)]
private ?string $activationTicketCode = null; private ?string $activationTicketCode = null;
@ -152,6 +158,12 @@ class PlugWarrantySerial
public function getBuyer(): ?Person { return $this->buyer; } public function getBuyer(): ?Person { return $this->buyer; }
public function setBuyer(?Person $buyer): self { $this->buyer = $buyer; return $this; } public function setBuyer(?Person $buyer): self { $this->buyer = $buyer; return $this; }
public function getAllocatedToDocumentType(): ?string { return $this->allocatedToDocumentType; }
public function setAllocatedToDocumentType(?string $type): self { $this->allocatedToDocumentType = $type; return $this; }
public function getAllocatedBy(): ?User { return $this->allocatedBy; }
public function setAllocatedBy(?User $user): self { $this->allocatedBy = $user; return $this; }
public function getActivationTicketCode(): ?string { return $this->activationTicketCode; } public function getActivationTicketCode(): ?string { return $this->activationTicketCode; }
public function setActivationTicketCode(?string $code): self { $this->activationTicketCode = $code; return $this; } public function setActivationTicketCode(?string $code): self { $this->activationTicketCode = $code; return $this; }
public function getActivationTicketSecret(): ?string { return $this->activationTicketSecret; } public function getActivationTicketSecret(): ?string { return $this->activationTicketSecret; }

View file

@ -96,6 +96,17 @@ class StoreroomTicket
#[ORM\JoinColumn(nullable: true)] #[ORM\JoinColumn(nullable: true)]
private ?User $approvedBy = null; private ?User $approvedBy = null;
// Completion fields
#[ORM\Column(nullable: true)]
private ?bool $completed = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $completedAt = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: true)]
private ?User $completedBy = null;
public function __construct() public function __construct()
{ {
$this->storeroomItems = new ArrayCollection(); $this->storeroomItems = new ArrayCollection();
@ -409,4 +420,38 @@ class StoreroomTicket
$this->approvedBy = $approvedBy; $this->approvedBy = $approvedBy;
return $this; return $this;
} }
// Completion methods
public function isCompleted(): ?bool
{
return $this->completed;
}
public function setCompleted(?bool $completed): static
{
$this->completed = $completed;
return $this;
}
public function getCompletedAt(): ?\DateTimeImmutable
{
return $this->completedAt;
}
public function setCompletedAt(?\DateTimeImmutable $completedAt): static
{
$this->completedAt = $completedAt;
return $this;
}
public function getCompletedBy(): ?User
{
return $this->completedBy;
}
public function setCompletedBy(?User $completedBy): static
{
$this->completedBy = $completedBy;
return $this;
}
} }

View file

@ -137,7 +137,7 @@ class Access
'user'=>$this->user 'user'=>$this->user
]); ]);
if($warehousePermission && $warehousePermission->isWarehouseManager()){ if($warehousePermission && $warehousePermission->isStorehelper()){
$warehouseRoles = ['commodity', 'store', 'plugWarrantyManager']; $warehouseRoles = ['commodity', 'store', 'plugWarrantyManager'];
if(in_array($roll, $warehouseRoles)){ if(in_array($roll, $warehouseRoles)){
return $accessArray; return $accessArray;

View file

@ -192,15 +192,15 @@ const rules = {
required: (value) => !!value || 'این فیلد الزامی است', required: (value) => !!value || 'این فیلد الزامی است',
minLength: (value) => !value || value.length >= 2 || 'حداقل 2 کاراکتر الزامی است', minLength: (value) => !value || value.length >= 2 || 'حداقل 2 کاراکتر الزامی است',
maxLength: (value) => !value || value.length <= 1000 || 'حداکثر 1000 کاراکتر مجاز است', maxLength: (value) => !value || value.length <= 1000 || 'حداکثر 1000 کاراکتر مجاز است',
email: (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || 'ایمیل معتبر وارد کنید', //email: (value) => !value || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) || 'ایمیل معتبر وارد کنید',
phone: (value) => !value || /^[\d\-\+\(\)\s]+$/.test(value) || 'شماره تلفن معتبر وارد کنید', //phone: (value) => !value || /^[\d\-\+\(\)\s]+$/.test(value) || 'شماره تلفن معتبر وارد کنید',
positive: (value) => !value || parseFloat(value) > 0 || 'مقدار باید مثبت باشد', positive: (value) => !value || parseFloat(value) > 0 || 'مقدار باید مثبت باشد',
positiveMoney: (value) => { positiveMoney: (value) => {
const numeric = parseMoneyInput(value) const numeric = parseMoney(value)
return numeric > 0 || 'مقدار باید مثبت باشد' return numeric > 0 || 'مقدار باید مثبت باشد'
}, },
maxAmount: (value) => !value || parseFloat(value) <= 999999999 || 'مبلغ نباید بیشتر از 999,999,999 باشد', maxAmount: (value) => !value || parseMoney(value) <= 999999999 || 'مبلغ نباید بیشتر از 999,999,999 باشد',
maxExchangeRate: (value) => !value || parseFloat(value) <= 999999 || 'نرخ تبدیل نباید بیشتر از 999,999 باشد' maxExchangeRate: (value) => !value || parseMoney(value) <= 999999 || 'نرخ تبدیل نباید بیشتر از 999,999 باشد'
} }
const parseMoney = (val) => { const parseMoney = (val) => {

View file

@ -12,35 +12,32 @@
<v-col cols="12" md="6"> <v-col cols="12" md="6">
<v-text-field v-model="formData.serialNumber" label="شماره سریال *" :rules="[rules.serialNumber]" required <v-text-field v-model="formData.serialNumber" label="شماره سریال *" :rules="[rules.serialNumber]" required
:disabled="isEdit" variant="outlined" density="comfortable" hide-details="auto" maxlength="50" counter> :disabled="isEdit" variant="outlined" density="comfortable" hide-details="auto" maxlength="50" counter>
<template #append> <template #prepend>
<v-btn icon small @click="openScanner" :disabled="isEdit" color="primary" variant="text"> <v-tooltip bottom size="small">
<v-icon size="20">mdi-qrcode-scan</v-icon> <template v-slot:activator="{ props }">
</v-btn> <v-icon v-bind="props" color="primary" @click.stop="showQrScanner = true">mdi-barcode-scan</v-icon>
</template>
<span>اسکن بارکد</span>
</v-tooltip>
</template> </template>
</v-text-field> </v-text-field>
</v-col> </v-col>
<v-col cols="12" md="6"> <v-col cols="12" md="6">
<Hcommoditysearch <Hcommoditysearch :model-value="formData.commodity_id ?? undefined"
: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 }" @update:modelValue="(val: number | Record<string, any>) => { formData.commodity_id = typeof val === 'number' ? val : (val as any)?.id ?? null }"
:return-object="false" :return-object="false" label="محصول *" :rules="[rules.commodity]" required class="serial-commodity" />
label="محصول *"
:rules="[rules.commodity]"
required
class="serial-commodity"
/>
</v-col> </v-col>
<v-col cols="12" md="6"> <v-col cols="12" md="6">
<h-date-picker v-model="formData.warrantyStartDate" label="تاریخ شروع گارانتی" :rules="[rules.date]" :ignore-year-range="true" dense <h-date-picker v-model="formData.warrantyStartDate" label="تاریخ شروع گارانتی" :rules="[rules.date]"
outlined hide-details="auto" /> :ignore-year-range="true" dense outlined hide-details="auto" />
</v-col> </v-col>
<v-col cols="12" md="6"> <v-col cols="12" md="6">
<h-date-picker v-model="formData.warrantyEndDate" label="تاریخ پایان گارانتی" <h-date-picker v-model="formData.warrantyEndDate" label="تاریخ پایان گارانتی"
:rules="[(v: any) => rules.endDate(v, formData.warrantyStartDate)]" :ignore-year-range="true" dense outlined :rules="[(v: any) => rules.endDate(v, formData.warrantyStartDate)]" :ignore-year-range="true" dense
hide-details="auto" /> outlined hide-details="auto" />
</v-col> </v-col>
<v-col cols="12" md="6"> <v-col cols="12" md="6">
@ -70,35 +67,7 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
<v-dialog v-model="showQrScanner" :max-width="isMobile ? '95vw' : 560" persistent> <BarcodeScanner v-model="showQrScanner" @detected="handleBarcodeScan" />
<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"> <v-snackbar v-model="showNotification" :color="notificationColor" :timeout="3000" location="top">
{{ notificationText }} {{ notificationText }}
@ -110,9 +79,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import { Html5Qrcode, Html5QrcodeSupportedFormats, Html5QrcodeScannerState } from 'html5-qrcode'
import Hcommoditysearch from '@/components/forms/Hcommoditysearch.vue' import Hcommoditysearch from '@/components/forms/Hcommoditysearch.vue'
import BarcodeScanner from '@/components/widgets/BarcodeScanner.vue'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@ -195,93 +164,6 @@ const showNotify = (t: string, c: 'success' | 'error' | 'warning' | 'info' = 'su
} }
const showQrScanner = ref(false) 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 save = async () => {
const res = await form.value?.validate() const res = await form.value?.validate()
@ -297,7 +179,6 @@ const save = async () => {
} }
const close = () => { const close = () => {
closeScanner()
emit('close') emit('close')
} }
@ -346,13 +227,24 @@ const handleCommoditySelect = (c: any) => {
watch(() => props.serial, () => nextTick(loadSerialData), { immediate: true }) watch(() => props.serial, () => nextTick(loadSerialData), { immediate: true })
watch(() => props.modelValue, v => { if (v) nextTick(loadSerialData) }) watch(() => props.modelValue, v => { if (v) nextTick(loadSerialData) })
onBeforeUnmount(() => { closeScanner() })
const handleBarcodeScan = (val: string) => {
formData.value.serialNumber = val
showQrScanner.value = false
}
</script> </script>
<style> <style>
/* normalize Hcommoditysearch height with other inputs */ /* normalize Hcommoditysearch height with other inputs */
.serial-commodity :deep(.v-field) { min-height: 56px; } .serial-commodity :deep(.v-field) {
.serial-commodity :deep(.v-field__input) { padding-top: 14px; padding-bottom: 14px; } min-height: 56px;
}
.serial-commodity :deep(.v-field__input) {
padding-top: 14px;
padding-bottom: 14px;
}
#qr-shaded-region { #qr-shaded-region {
display: none !important; display: none !important;
} }
@ -453,7 +345,11 @@ video {
} }
} }
.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 { .v-input--density-compact .v-field--variant-outlined {
height: 3rem; height: 3rem !important;
}
.mdi-barcode-scan::before {
font-size: 25px !important;
} }
</style> </style>

View file

@ -0,0 +1,255 @@
<template>
<v-dialog v-model="internalShow" max-width="500" persistent>
<v-card>
<v-toolbar color="primary" dark>
<v-toolbar-title>اسکن بارکد</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="close"><v-icon>mdi-close</v-icon></v-btn>
</v-toolbar>
<v-card-text class="pa-0">
<div v-if="errorMessage" class="error-box">
<v-icon color="red" class="mr-2">mdi-alert-circle</v-icon>
{{ errorMessage }}
<div v-if="canRetry" class="mt-2">
<v-btn size="small" color="primary" @click="requestPermission">تلاش دوباره</v-btn>
</div>
</div>
<div v-else class="scanner-container">
<div v-if="overlay" class="scanner-overlay">
<div class="scanner-line"></div>
<div class="scanner-corner top-left"></div>
<div class="scanner-corner top-right"></div>
<div class="scanner-corner bottom-left"></div>
<div class="scanner-corner bottom-right"></div>
</div>
<qrcode-stream :camera="camera" :torch="isFlashOn" :formats="formats"
:track="overlay ? paintOutline : undefined" @detect="onDetect" @init="onInit" />
</div>
</v-card-text>
<v-card-actions class="pa-2" v-if="!errorMessage">
<v-btn @click="toggleFlash" :color="isFlashOn ? 'primary' : 'grey'">
<v-icon start>mdi-flash</v-icon> فلش
</v-btn>
<v-btn @click="toggleCamera" :disabled="availableCameras.length < 2">
<v-icon start>mdi-camera-flip</v-icon>
{{ isBackCamera ? 'عقب' : 'جلو' }}
</v-btn>
<v-spacer />
<v-btn color="primary" variant="tonal" @click="close">بستن</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { QrcodeStream } from 'vue3-qrcode-reader'
const props = defineProps({
modelValue: Boolean,
formats: { type: Array as () => string[], default: () => ['QR_CODE', 'EAN_13', 'CODE_128'] },
overlay: { type: Boolean, default: true }
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'detected', barcode: string): void
(e: 'error', error: Error): void
(e: 'close'): void
}>()
const internalShow = ref(props.modelValue)
watch(() => props.modelValue, val => internalShow.value = val)
watch(internalShow, val => emit('update:modelValue', val))
const camera = ref<'auto' | 'off' | 'user' | 'environment'>('environment')
const isFlashOn = ref(false)
const availableCameras = ref<MediaDeviceInfo[]>([])
const isBackCamera = ref(true)
const errorMessage = ref('')
const canRetry = ref(false)
const paintOutline = (location: any, ctx: CanvasRenderingContext2D) => {
if (!location) return
const { topLeftCorner, topRightCorner, bottomLeftCorner, bottomRightCorner } = location
ctx.strokeStyle = '#00ff00'
ctx.lineWidth = 4
ctx.beginPath()
ctx.moveTo(topLeftCorner.x, topLeftCorner.y)
ctx.lineTo(topRightCorner.x, topRightCorner.y)
ctx.lineTo(bottomRightCorner.x, bottomRightCorner.y)
ctx.lineTo(bottomLeftCorner.x, bottomLeftCorner.y)
ctx.closePath()
ctx.stroke()
}
const onInit = async (promise: Promise<void>) => {
try {
await promise
errorMessage.value = ''
canRetry.value = false
} catch (err: any) {
if (err.name === 'NotAllowedError' || err.message?.includes('permission')) {
errorMessage.value = 'برای استفاده از اسکنر لطفاً اجازه دسترسی به دوربین را بدهید.'
canRetry.value = true
emit('error', new Error(errorMessage.value))
return
}
if (err.name === 'NotFoundError' || err.message?.includes('camera') || err.message?.includes('device')) {
errorMessage.value = 'هیچ دوربینی در دستگاه شما یافت نشد.'
canRetry.value = false
emit('error', new Error(errorMessage.value))
return
}
errorMessage.value = 'خطا در راه‌اندازی دوربین: ' + (err.message || 'خطای نامشخص')
canRetry.value = false
emit('error', new Error(errorMessage.value))
}
}
const onDetect = (promise: Promise<any>) => {
promise.then(result => {
if (result?.content) {
emit('detected', result.content)
close()
}
}).catch(err => {
console.error('Error decoding QR:', err)
})
}
const toggleFlash = () => isFlashOn.value = !isFlashOn.value
const toggleCamera = () => {
camera.value = camera.value === 'environment' ? 'user' : 'environment'
isBackCamera.value = camera.value === 'environment'
}
const requestPermission = async () => {
try {
await navigator.mediaDevices.getUserMedia({ video: true })
errorMessage.value = ''
canRetry.value = false
} catch {
errorMessage.value = 'دسترسی به دوربین رد شد.'
canRetry.value = true
}
}
const close = () => {
internalShow.value = false
emit('close')
}
onMounted(async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices()
availableCameras.value = devices.filter(device => device.kind === 'videoinput')
if (availableCameras.value.length === 0) {
errorMessage.value = 'هیچ دوربینی در دستگاه شما یافت نشد.'
canRetry.value = false
emit('error', new Error(errorMessage.value))
}
} catch {
errorMessage.value = 'خطا در بررسی دوربین‌های موجود.'
canRetry.value = false
emit('error', new Error(errorMessage.value))
}
})
</script>
<style scoped>
.scanner-container {
width: 100%;
height: 300px;
background: #000;
position: relative;
overflow: hidden;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.scanner-container video {
width: 100%;
height: 100%;
object-fit: cover;
}
.scanner-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
pointer-events: none;
}
.scanner-line {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 2px;
background: #00ff00;
animation: scan 2s linear infinite;
}
.scanner-corner {
position: absolute;
width: 20px;
height: 20px;
border-color: #00ff00;
border-style: solid;
border-width: 0;
}
.scanner-corner.top-left {
top: 20%;
left: 20%;
border-top-width: 4px;
border-left-width: 4px;
}
.scanner-corner.top-right {
top: 20%;
right: 20%;
border-top-width: 4px;
border-right-width: 4px;
}
.scanner-corner.bottom-left {
bottom: 20%;
left: 20%;
border-bottom-width: 4px;
border-left-width: 4px;
}
.scanner-corner.bottom-right {
bottom: 20%;
right: 20%;
border-bottom-width: 4px;
border-right-width: 4px;
}
@keyframes scan {
0% {
transform: translateY(-50px);
}
50% {
transform: translateY(50px);
}
100% {
transform: translateY(-50px);
}
}
.error-box {
padding: 16px;
color: red;
text-align: center;
}
</style>

View file

@ -153,6 +153,7 @@ const fa_lang = {
storeroom: "انبار", storeroom: "انبار",
storeroom_title: "انبار‌داری", storeroom_title: "انبار‌داری",
storeroom_ticket: "حواله انبار", storeroom_ticket: "حواله انبار",
storeroom_ticket_helper: "کمک انباردار",
storerooms: "انبار‌ها", storerooms: "انبار‌ها",
commodity_exist_count: "موجودی کالا", commodity_exist_count: "موجودی کالا",
inventory: "موجودی کالا", inventory: "موجودی کالا",

View file

@ -974,12 +974,24 @@ const router = createRouter({
component: () => component: () =>
import('../views/acc/storeroom/io/ticketList.vue'), import('../views/acc/storeroom/io/ticketList.vue'),
}, },
{
path: 'storeroom/tickets/list/helper',
name: 'storeroom_tickets_list_helper',
component: () =>
import('../views/acc/storeroom/io/ticketListHelper.vue'),
},
{ {
path: 'storeroom/ticket/view/:id', path: 'storeroom/ticket/view/:id',
name: 'storeroom_ticket_view', name: 'storeroom_ticket_view',
component: () => component: () =>
import('../views/acc/storeroom/io/view.vue'), import('../views/acc/storeroom/io/view.vue'),
}, },
{
path: 'storeroom/ticket/complete/:id',
name: 'storeroom_ticket_complete',
component: () =>
import('../views/acc/storeroom/io/complete.vue'),
},
{ {
path: 'storeroom/new/ticket/buy/:doc/:storeID', path: 'storeroom/new/ticket/buy/:doc/:storeID',
name: 'storeroom_new_ticket_buy', name: 'storeroom_new_ticket_buy',

View file

@ -216,7 +216,8 @@ 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/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/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/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 }, { path: '/acc/plugins/import-workflow', key: 'I', label: 'مدیریت واردات کالا', ctrl: true, shift: true, permission: () => this.permissions.plugImportWorkflow },
{ path: '/acc/storeroom/tickets/list/helper', key: 'I', label: this.$t('drawer.storeroom_ticket_helper'), ctrl: true, shift: true, permission: () => (this.permissions.storehelper || this.permissions.store) && this.isPluginActive('accpro') },
]; ];
}, },
restorePermissions(shortcuts) { restorePermissions(shortcuts) {
@ -555,7 +556,7 @@ export default {
</v-list-item> </v-list-item>
</v-list-group> </v-list-group>
<v-list-subheader color="primary">{{ $t('drawer.acc_store_tools') }}</v-list-subheader> <v-list-subheader color="primary">{{ $t('drawer.acc_store_tools') }}</v-list-subheader>
<v-list-group v-show="permissions.store"> <v-list-group v-show="permissions.store || permissions.storehelper">
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-list-item class="text-dark" v-bind="props" prepend-icon="mdi-store" <v-list-item class="text-dark" v-bind="props" prepend-icon="mdi-store"
:title="$t('drawer.storeroom_title')"></v-list-item> :title="$t('drawer.storeroom_title')"></v-list-item>
@ -587,6 +588,13 @@ export default {
</v-tooltip> </v-tooltip>
</template> </template>
</v-list-item> </v-list-item>
<v-list-item v-if="(permissions.store || permissions.storehelper) && this.isPluginActive('accpro')" to="/acc/storeroom/tickets/list/helper">
<v-list-item-title>
{{ $t('drawer.storeroom_ticket_helper') }}
<span v-if="isCtrlShiftPressed" class="shortcut-key">{{ getShortcutKey('/acc/storeroom/tickets/list/helper')
}}</span>
</v-list-item-title>
</v-list-item>
<v-list-item v-if="permissions.store" to="/acc/storeroom/commodity/check/exist"> <v-list-item v-if="permissions.store" to="/acc/storeroom/commodity/check/exist">
<v-list-item-title> <v-list-item-title>
{{ $t('drawer.commodity_exist_count') }} {{ $t('drawer.commodity_exist_count') }}
@ -807,13 +815,13 @@ export default {
</v-list-item> </v-list-item>
</v-list-group> </v-list-group>
<v-list-subheader color="primary">{{ $t('drawer.services') }}</v-list-subheader> <v-list-subheader color="primary">{{ $t('drawer.services') }}</v-list-subheader>
<v-list-group v-show="isPluginActive('import-workflow') && permissions.importWorkflow"> <v-list-group v-show="isPluginActive('import-workflow') && permissions.plugImportWorkflow">
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-list-item class="text-dark" v-bind="props" title="مدیریت واردات کالا"> <v-list-item class="text-dark" v-bind="props" title="مدیریت واردات کالا">
<template v-slot:prepend><v-icon icon="mdi-import" color="primary"></v-icon></template> <template v-slot:prepend><v-icon icon="mdi-import" color="primary"></v-icon></template>
</v-list-item> </v-list-item>
</template> </template>
<v-list-item v-if="permissions.importWorkflow" to="/acc/plugins/import-workflow/list"> <v-list-item v-if="permissions.plugImportWorkflow" to="/acc/plugins/import-workflow/list">
<v-list-item-title> <v-list-item-title>
لیست پروندههای واردات لیست پروندههای واردات
<span v-if="isCtrlShiftPressed" class="shortcut-key">{{ getShortcutKey('/acc/plugins/import-workflow/list') }}</span> <span v-if="isCtrlShiftPressed" class="shortcut-key">{{ getShortcutKey('/acc/plugins/import-workflow/list') }}</span>

View file

@ -11,8 +11,8 @@
</p> </p>
<div class="plugin-version"> <div class="plugin-version">
<span class="version-badge">نسخه 1.0.0</span> <span class="version-badge">نسخه 1.0.0</span>
<span v-if="isPluginActive('importWorkflow')" class="status-badge active">فعال</span> <span v-if="isPluginActive('import-workflow')" class="status-badge active">فعال</span>
<RouterLink to="/acc/plugin-center/view-end/import-workflow" v-if="!isPluginActive('importWorkflow')"> <RouterLink to="/acc/plugin-center/view-end/import-workflow" v-if="!isPluginActive('import-workflow')">
<span class="status-badge active text-white d-flex align-items-center"> <span class="status-badge active text-white d-flex align-items-center">
<i class="fa fa-shopping-cart me-1"></i> <i class="fa fa-shopping-cart me-1"></i>
خرید خرید
@ -123,7 +123,7 @@
</div> </div>
<div class="intro-footer"> <div class="intro-footer">
<div v-if="isPluginActive('importWorkflow')" class="action-buttons"> <div v-if="isPluginActive('import-workflow')" class="action-buttons">
<router-link to="/acc/plugins/import-workflow/list" class="btn btn-success"> <router-link to="/acc/plugins/import-workflow/list" class="btn btn-success">
<i class="fas fa-list"></i> <i class="fas fa-list"></i>
ورود به مدیریت واردات ورود به مدیریت واردات

View file

@ -11,10 +11,14 @@
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="info" variant="outlined" prepend-icon="mdi-share-variant" @click="openActivationLinkDialog" <v-btn color="info" variant="outlined" prepend-icon="mdi-share-variant" @click="openActivationLinkDialog"
class="ml-2"> class="ml-2">
اشتراک گذاری لینک فعالسازی <div class="button-title">
اشتراک گذاری لینک فعالسازی
</div>
</v-btn> </v-btn>
<v-btn color="primary" variant="outlined" prepend-icon="mdi-cog" @click="goToWarrantySettings"> <v-btn color="primary" variant="outlined" prepend-icon="mdi-cog" @click="goToWarrantySettings">
تنظیمات گارانتی <div class="button-title">
تنظیمات گارانتی
</div>
</v-btn> </v-btn>
</v-toolbar> </v-toolbar>
<div class="warranty-plugin"> <div class="warranty-plugin">
@ -154,55 +158,76 @@
@close="closeBulkImportDialog" /> @close="closeBulkImportDialog" />
<!-- Activation Link Dialog --> <!-- Activation Link Dialog -->
<v-dialog v-model="showActivationLinkDialog" max-width="600"> <v-dialog v-model="showActivationLinkDialog" :max-width="$vuetify.display.smAndDown ? '95%' : '600'" :fullscreen="$vuetify.display.xs">
<v-card> <v-card>
<v-card-title class="d-flex align-center" style="padding: 20px !important;"> <v-card-title class="d-flex align-center pa-4 pa-sm-6">
<v-icon class="ml-2" color="info">mdi-share-variant</v-icon> <v-icon class="ml-2" color="info">mdi-share-variant</v-icon>
اشتراک گذاری لینک فعالسازی گارانتی <span class="text-h6 text-sm-h5">اشتراک گذاری لینک فعالسازی گارانتی</span>
</v-card-title> </v-card-title>
<v-card-text> <v-card-text class="pa-4 pa-sm-6">
<v-alert type="info" variant="tonal" class="mb-4"> <v-alert type="info" variant="tonal" class="mb-4">
<strong>نکته:</strong> این لینک برای فعالسازی گارانتی توسط مشتریان استفاده میشود. <strong>نکته:</strong> این لینک برای فعالسازی گارانتی توسط مشتریان استفاده میشود.
</v-alert> </v-alert>
<div class="mb-4"> <div class="mb-4">
<label class="text-body-2 font-weight-medium mb-2 d-block">لینک فعالسازی گارانتی:</label> <label class="text-body-2 font-weight-medium mb-2 d-block">لینک فعالسازی گارانتی:</label>
<div class="d-flex align-center flex-row-reverse gap-2"> <div class="d-flex flex-column flex-sm-row align-stretch align-sm-center gap-2">
<v-text-field :model-value="activationLink" readonly variant="outlined" density="compact" <v-text-field
class="flex-grow-1 text-left" hide-details></v-text-field> :model-value="activationLink"
<v-btn color="primary" variant="tonal" @click="copyActivationLink" class="ml-2" :loading="copying"> readonly
variant="outlined"
density="compact"
class="flex-grow-1 text-left"
hide-details
:class="$vuetify.display.xs ? 'mb-2' : ''"
></v-text-field>
<v-btn
color="primary"
variant="tonal"
@click="copyActivationLink"
:loading="copying"
:class="$vuetify.display.xs ? 'w-100' : ''"
:size="$vuetify.display.xs ? 'large' : 'default'"
>
<v-icon>mdi-content-copy</v-icon> <v-icon>mdi-content-copy</v-icon>
<span v-if="$vuetify.display.xs" class="mr-2">کپی لینک</span>
</v-btn> </v-btn>
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<h4 class="text-h6 mb-3">راهنمای استفاده:</h4> <h4 class="text-h6 mb-3">راهنمای استفاده:</h4>
<v-list density="compact"> <v-list density="compact" class="bg-transparent">
<v-list-item> <v-list-item class="px-0">
<template #prepend> <template #prepend>
<v-icon color="primary" size="small">mdi-numeric-1-circle</v-icon> <v-icon color="primary" size="small">mdi-numeric-1-circle</v-icon>
</template> </template>
<v-list-item-title>این لینک را برای مشتریان ارسال کنید</v-list-item-title> <v-list-item-title class="text-body-2 text-sm-body-1">این لینک را برای مشتریان ارسال کنید</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item> <v-list-item class="px-0">
<template #prepend> <template #prepend>
<v-icon color="primary" size="small">mdi-numeric-2-circle</v-icon> <v-icon color="primary" size="small">mdi-numeric-2-circle</v-icon>
</template> </template>
<v-list-item-title>مشتری با مراجعه به لینک میتواند گارانتی خود را فعال کند</v-list-item-title> <v-list-item-title class="text-body-2 text-sm-body-1">مشتری با مراجعه به لینک میتواند گارانتی خود را فعال کند</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item> <v-list-item class="px-0">
<template #prepend> <template #prepend>
<v-icon color="primary" size="small">mdi-numeric-3-circle</v-icon> <v-icon color="primary" size="small">mdi-numeric-3-circle</v-icon>
</template> </template>
<v-list-item-title>پس از فعالسازی، وضعیت گارانتی در سیستم بهروزرسانی میشود</v-list-item-title> <v-list-item-title class="text-body-2 text-sm-body-1">پس از فعالسازی، وضعیت گارانتی در سیستم بهروزرسانی میشود</v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
</div> </div>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions class="pa-4 pa-sm-6">
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn @click="showActivationLinkDialog = false">بستن</v-btn> <v-btn
@click="showActivationLinkDialog = false"
:size="$vuetify.display.xs ? 'large' : 'default'"
:class="$vuetify.display.xs ? 'w-100' : ''"
>
بستن
</v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
@ -841,4 +866,24 @@ onMounted(async () => {
text-align: center; text-align: center;
} }
} }
</style>
<style>
.button-title {
display: block;
}
@media (max-width: 768px) {
.button-title {
display: none !important;
}
.v-btn__prepend {
margin: 0 !important;
}
}
.v-list-item-title {
white-space: unset !important;
text-overflow: unset !important;
}
</style> </style>

View file

@ -378,7 +378,7 @@
<v-card-text> <v-card-text>
<v-switch <v-switch
v-model="content.requireWarrantyOnDelivery" v-model="content.requireWarrantyOnDelivery"
label="الزام ثبت گارانتی هنگام صدور حواله خروج" label="الزام ثبت گارانتی هنگام تکمیل پروسه حواله خروج"
color="primary" color="primary"
hide-details hide-details
class="mb-2" class="mb-2"

View file

@ -372,6 +372,18 @@
:disabled="loadingSwitches.store" :disabled="loadingSwitches.store"
></v-switch> ></v-switch>
</v-list-item> </v-list-item>
<v-list-item>
<v-switch
v-model="info.storehelper"
label="کمک انباردار"
@change="savePerms('storehelper')"
hide-details
color="success"
density="comfortable"
:loading="loadingSwitches.storehelper"
:disabled="loadingSwitches.storehelper"
></v-switch>
</v-list-item>
<v-list-item> <v-list-item>
<v-switch <v-switch
v-model="info.wallet" v-model="info.wallet"
@ -401,53 +413,6 @@
</v-card> </v-card>
</v-col> </v-col>
</v-row> </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-row v-if="isPluginActive('accpro')" class="mt-4">
<v-col cols="12"> <v-col cols="12">
<v-card-title class="text-h6 font-weight-bold mb-4">بسته حسابداری پیشرفته</v-card-title> <v-card-title class="text-h6 font-weight-bold mb-4">بسته حسابداری پیشرفته</v-card-title>
@ -710,6 +675,58 @@
</v-card> </v-card>
</v-col> </v-col>
</v-row> </v-row>
<v-row v-if="isPluginActive('warranty')" class="mt-4">
<v-col cols="12">
<v-card-title class="text-h6 font-weight-bold mb-4">افزونه گارانتی</v-card-title>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="h-100">
<v-card-text>
<v-list>
<v-list-item>
<v-switch
v-model="info.plugWarranty"
label="مدیریت گارانتی"
@change="savePerms('plugWarranty')"
hide-details
color="success"
density="comfortable"
:loading="loadingSwitches.plugWarranty"
:disabled="loadingSwitches.plugWarranty"
></v-switch>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row v-if="isPluginActive('import-workflow')" class="mt-4">
<v-col cols="12">
<v-card-title class="text-h6 font-weight-bold mb-4">افزونه مدیریت واردات کالا</v-card-title>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="h-100">
<v-card-text>
<v-list>
<v-list-item>
<v-switch
v-model="info.plugImportWorkflow"
label="مدیریت واردات کالا"
@change="savePerms('plugImportWorkflow')"
hide-details
color="success"
density="comfortable"
:loading="loadingSwitches.plugImportWorkflow"
:disabled="loadingSwitches.plugImportWorkflow"
></v-switch>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card> </v-card>
</v-container> </v-container>
<v-snackbar <v-snackbar
@ -792,7 +809,9 @@ export default {
plugTaxSettings: false, plugTaxSettings: false,
inquiry: false, inquiry: false,
ai: false, ai: false,
warehouseManager: false storehelper: false,
plugWarranty: false,
plugImportWorkflow: false
}; };
axios.post('/api/business/get/user/permissions', axios.post('/api/business/get/user/permissions',

View file

@ -0,0 +1,838 @@
<template>
<v-toolbar color="toolbar" title="تکمیل پروسه حواله انبار">
<template v-slot:prepend>
<v-tooltip text="بازگشت" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" @click="$router.back()" class="d-none d-sm-flex" variant="text" icon="mdi-arrow-right" />
</template>
</v-tooltip>
</template>
<v-spacer />
<v-btn
color="success"
prepend-icon="mdi-check-decagram"
@click="completeTicket"
:loading="completing"
:disabled="!canComplete"
>
تکمیل پروسه
</v-btn>
</v-toolbar>
<v-container fluid>
<!-- Loading State -->
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 400px;">
<v-card class="pa-8 text-center">
<v-progress-circular
indeterminate
color="primary"
size="64"
class="mb-4"
></v-progress-circular>
<div class="text-h6 text-grey-darken-1">در حال بارگذاری اطلاعات حواله...</div>
<div class="text-body-2 text-grey mt-2">لطفاً صبر کنید</div>
</v-card>
</div>
<!-- Content when loaded -->
<div v-else>
<!-- Ticket Information -->
<v-row>
<v-col cols="12" md="4">
<v-card class="mb-4">
<v-card-title class="d-flex align-center mb-4">
<v-icon class="ml-2" color="primary">mdi-file-document</v-icon>
اطلاعات حواله
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="6">
<div class="text-subtitle-2 text-grey">شماره حواله:</div>
<div class="text-body-1 font-weight-medium">{{ item.ticket?.code }}</div>
</v-col>
<v-col cols="6">
<div class="text-subtitle-2 text-grey">تاریخ:</div>
<div class="text-body-1 font-weight-medium">{{ (item.ticket?.date) }}</div>
</v-col>
<v-col cols="6">
<div class="text-subtitle-2 text-grey">نوع:</div>
<div class="text-body-1 font-weight-medium">{{ item.ticket?.typeString }}</div>
</v-col>
<v-col cols="6">
<div class="text-subtitle-2 text-grey">انبار:</div>
<div class="text-body-1 font-weight-medium">{{ item.ticket?.storeroom?.manager }}</div>
</v-col>
<v-col cols="12" v-if="item.ticket?.des">
<div class="text-subtitle-2 text-grey">توضیحات:</div>
<div class="text-body-1">{{ item.ticket.des }}</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card class="mb-4">
<v-card-title class="d-flex align-center mb-4">
<v-icon class="ml-2" color="primary">mdi-account</v-icon>
اطلاعات طرف حساب
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="6">
<div class="text-subtitle-2 text-grey">نام:</div>
<div class="text-body-1 font-weight-medium">{{ item.person?.nikename }}</div>
</v-col>
<v-col cols="6">
<div class="text-subtitle-2 text-grey">موبایل:</div>
<div class="text-body-1 font-weight-medium">{{ item.person?.mobile }}</div>
</v-col>
<v-col cols="12" v-if="item.person?.address">
<div class="text-subtitle-2 text-grey">آدرس:</div>
<div class="text-body-1">{{ item.person.address }}</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
<v-card class="mb-4">
<v-card-title class="d-flex align-center mb-4">
<v-icon class="ml-2" color="primary">mdi-information</v-icon>
وضعیت
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="6">
<div class="text-subtitle-2 text-grey">وضعیت تایید:</div>
<v-chip :color="item.ticket?.approved ? 'success' : 'warning'" size="small">
{{ item.ticket?.approved ? 'تایید شده' : 'در انتظار تایید' }}
</v-chip>
</v-col>
<v-col cols="6">
<div class="text-subtitle-2 text-grey">وضعیت تکمیل:</div>
<v-chip :color="item.ticket?.completed ? 'success' : 'info'" size="small">
{{ item.ticket?.completed ? 'تکمیل شده' : 'در انتظار تکمیل' }}
</v-chip>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
</v-col>
</v-row>
<!-- Items with Warranty Allocation - Full Width -->
<v-row v-if="isPluginActive('warranty')">
<v-col cols="12">
<v-card>
<v-card-title class="d-flex align-center mb-4">
<v-icon class="ml-2" color="primary">mdi-package-variant</v-icon>
اقلام حواله و تخصیص گارانتی
</v-card-title>
<v-card-text>
<div v-for="(row, rowIndex) in item.rows" :key="rowIndex" class="mb-6">
<v-card variant="outlined" class="mb-3">
<v-card-title class="text-subtitle-1 pa-4 mb-4">
{{ row.commodity?.name }}
<v-chip size="small" color="primary" class="ml-2">
{{ row.count }} عدد
</v-chip>
</v-card-title>
<v-card-text>
<!-- Warranty Allocation Section - Like sell.vue -->
<div v-if="isWarrantyRequired(row)" class="mb-4">
<div class="lines-wrap">
<div class="line-row header">
<div class="col-idx">#</div>
<div class="col-serial">سریال کالا</div>
<div class="col-actions"></div>
<div class="col-warranty">گارانتی آزاد این کالا</div>
<div class="col-actions"></div>
</div>
<div v-for="(line, lineIndex) in getWarrantyLines(row)" :key="`ln-${rowIndex}-${lineIndex}`" class="line-row">
<div class="col-idx">{{ lineIndex + 1 }}</div>
<div class="col-serial">
<v-text-field
v-model.trim="line.serialNumber"
variant="outlined"
density="compact"
hide-details="auto"
placeholder="شماره سریال را وارد کنید"
:disabled="line.isAllocated"
/>
</div>
<div class="col-actions">
<v-chip v-if="line.isAllocated" color="success" size="small">
تخصیص داده شده
</v-chip>
</div>
<div class="col-warranty">
<v-autocomplete
v-model="line.warrantySerial"
:items="filteredWarranties(rowIndex, lineIndex)"
item-title="label"
item-value="serialNumber"
variant="outlined"
density="compact"
hide-details="auto"
clearable
:loading="row.loadingWarranties"
:no-data-text="row.loadingWarranties ? 'در حال بارگذاری...' : (row.warrantiesLoaded ? 'موردی یافت نشد' : 'برای مشاهده لیست، کلیک کنید')"
:filter="(i: any, q: any) => String(i.label || '').toLowerCase().includes(String(q || '').toLowerCase())"
placeholder="انتخاب/وارد کردن کد گارانتی"
:disabled="line.isAllocated"
@update:menu="val => { if (val) ensureWarrantiesLoaded(rowIndex) }"
@update:search="ensureWarrantiesLoaded(rowIndex)"
@blur="onWarrantyBlur(rowIndex, lineIndex)"
/>
</div>
<div class="col-actions">
<v-btn
v-if="!line.isAllocated"
icon size="small"
color="secondary"
variant="text"
@click="searchWarranty(rowIndex, lineIndex)"
:loading="line.searching"
class="mr-1"
>
<v-icon>mdi-magnify</v-icon>
</v-btn>
<v-btn
v-if="!line.isAllocated"
icon size="small"
color="primary"
variant="text"
@click="openBarcodeScanner(rowIndex, lineIndex)"
title="اسکن بارکد"
>
<v-icon>mdi-barcode-scan</v-icon>
</v-btn>
</div>
</div>
</div>
</div>
<!-- Item Details -->
<v-row>
<v-col cols="6">
<div class="text-subtitle-2 text-grey">کد کالا:</div>
<div class="text-body-2">{{ row.commodity?.code }}</div>
</v-col>
<v-col cols="6">
<div class="text-subtitle-2 text-grey">واحد:</div>
<div class="text-body-2">{{ row.commodity?.unit?.name }}</div>
</v-col>
<v-col cols="12" v-if="row.des">
<div class="text-subtitle-2 text-grey">توضیحات:</div>
<div class="text-body-2">{{ row.des }}</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</v-container>
<!-- Barcode Scanner Dialog -->
<v-dialog v-model="showBarcodeScanner" max-width="500">
<v-card>
<v-card-title>اسکن بارکد</v-card-title>
<v-card-text>
<BarcodeScanner
@barcode-scanned="onBarcodeScanned"
@close="showBarcodeScanner = false"
/>
</v-card-text>
</v-card>
</v-dialog>
<BarcodeScanner v-model="showBarcodeScanner" @detected="onBarcodeScanned" />
<!-- Warranty Search Dialog -->
<v-dialog v-model="showWarrantySearch" max-width="600">
<v-card>
<v-card-title>جستجوی گارانتی</v-card-title>
<v-card-text>
<v-text-field
v-model="warrantySearchQuery"
label="جستجو بر اساس کد گارانتی یا نام محصول"
variant="outlined"
prepend-icon="mdi-magnify"
@input="searchWarranties"
></v-text-field>
<v-list v-if="warrantySearchResults.length > 0">
<v-list-item
v-for="warranty in warrantySearchResults"
:key="warranty.id"
@click="selectWarranty(warranty)"
class="mb-2"
>
<v-list-item-title>{{ warranty.serialNumber }}</v-list-item-title>
<v-list-item-subtitle>{{ warranty.commodity?.name }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-alert v-else-if="warrantySearchQuery" type="info">
نتیجهای یافت نشد
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="showWarrantySearch = false">بستن</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Success Dialog -->
<v-dialog v-model="showSuccessDialog" max-width="400">
<v-card>
<v-card-title class="text-center" style="padding: 30px !important;">
<v-icon color="success" size="large" class="mb-2">mdi-check-circle</v-icon>
<div>پروسه با موفقیت تکمیل شد</div>
</v-card-title>
<v-card-text v-if="isPluginActive('warranty')" class="text-center">
حواله انبار با موفقیت تکمیل شد و گارانتیها تخصیص داده شدند.
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" @click="goBack">بازگشت</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Error Snackbar -->
<v-snackbar v-model="showError" color="error" timeout="5000">
{{ errorMessage }}
</v-snackbar>
<!-- Success Snackbar -->
<v-snackbar v-model="showSuccess" color="success" timeout="3000">
{{ successMessage }}
</v-snackbar>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import axios from 'axios'
import moment from 'jalali-moment'
import Swal from 'sweetalert2'
import BarcodeScanner from '@/components/widgets/BarcodeScanner.vue'
const router = useRouter()
const route = useRoute()
interface Business {
legal_name: string
}
interface Storeroom {
manager: string
}
interface Ticket {
id: number
date: string | null
code: string | null
des: string
type: string
typeString: string
storeroom: Storeroom
preview: boolean
approved: boolean
completed?: boolean
}
interface Person {
nikename: string | null
mobile: string
address: string
tel: string
codeeqtesadi: string
keshvar: string
ostan: string
shahr: string
postalcode: string
}
interface Commodity {
code: string
name: string
id: number
unit: {
name: string
}
}
interface Row {
commodity: Commodity
count: number
hesabdariCount: number
remain: number
des: string
referal: string
warrantyLines?: WarrantyLine[]
loadingWarranties?: boolean
warrantiesLoaded?: boolean
availableWarranties?: Array<{ label: string; serialNumber: string }>
}
interface WarrantyLine {
warrantySerial: string
serialNumber: string
warrantyError?: string
serialError?: string
searching?: boolean
isAllocated?: boolean
}
interface Item {
ticket: Ticket
rows: Row[]
person: Person
transferType: any
}
const loading = ref(false)
const completing = ref(false)
const bid = ref<Business>({ legal_name: '' })
const warrantySettings = ref({
requireWarrantyOnDelivery: false,
matchWarrantyToSerial: false
})
const plugins = ref<any>({})
const item = ref<Item>({
ticket: {
id: 0,
date: null,
code: null,
des: '',
type: '',
typeString: '',
storeroom: {
manager: ''
},
preview: false,
approved: false
},
rows: [],
person: {
nikename: null,
mobile: '',
address: '',
tel: '',
codeeqtesadi: '',
keshvar: '',
ostan: '',
shahr: '',
postalcode: ''
},
transferType: null
})
// Warranty search
const showWarrantySearch = ref(false)
const warrantySearchQuery = ref('')
const warrantySearchResults = ref<any[]>([])
const currentSearchContext = ref<{ rowIndex: number; lineIndex: number } | null>(null)
// Success/Error handling
const showSuccessDialog = ref(false)
const showError = ref(false)
const errorMessage = ref('')
const showSuccess = ref(false)
const successMessage = ref('')
// Barcode Scanner
const showBarcodeScanner = ref(false)
const currentBarcodeScannerContext = ref<{ rowIndex: number; lineIndex: number } | null>(null)
const loadData = async () => {
loading.value = true
try {
const [ticketResponse, businessResponse, warrantyResponse, allocatedWarrantiesResponse, pluginsResponse] = await Promise.all([
axios.post(`/api/storeroom/tickets/info/${route.params.id}`),
axios.post(`/api/business/get/info/${localStorage.getItem('activeBid')}`),
axios.get('/api/plugins/warranty/settings/get').catch(() => ({ data: { requireWarrantyOnDelivery: false, matchWarrantyToSerial: false } })),
axios.get(`/api/plugins/warranty/serials/by-storeroom-ticket/${route.params.id}`).catch(() => ({ data: { items: [] } })),
axios.post('/api/plugin/get/actives')
])
plugins.value = pluginsResponse.data
item.value.ticket = ticketResponse.data.ticket
item.value.person = ticketResponse.data.person
item.value.transferType = ticketResponse.data.transferType
item.value.rows = ticketResponse.data.commodities.map((row: Row) => ({
...row,
warrantyLines: [],
loadingWarranties: false,
warrantiesLoaded: false,
availableWarranties: []
}))
bid.value = businessResponse.data
warrantySettings.value.requireWarrantyOnDelivery = !!(warrantyResponse?.data?.requireWarrantyOnDelivery)
warrantySettings.value.matchWarrantyToSerial = !!(warrantyResponse?.data?.matchWarrantyToSerial)
// Get already allocated warranties
const allocatedWarranties = allocatedWarrantiesResponse?.data?.items || []
// Initialize warranty lines for each row
item.value.rows.forEach((row, rowIndex) => {
if (isWarrantyRequired(row)) {
// Get allocated warranties for this specific commodity
const commodityAllocatedWarranties = allocatedWarranties.filter((w: any) =>
w.commodity?.id === row.commodity?.id
)
// Always create warranty lines based on row.count, regardless of allocated warranties
row.warrantyLines = Array.from({ length: row.count }, (_, lineIndex) => {
// Check if this line already has an allocated warranty
const allocatedWarranty = commodityAllocatedWarranties[lineIndex] || null
return {
warrantySerial: allocatedWarranty?.serialNumber || '',
serialNumber: allocatedWarranty?.commoditySerial || '',
warrantyError: '',
serialError: '',
searching: false,
isAllocated: !!allocatedWarranty
}
})
} else {
row.warrantyLines = []
}
})
} catch (error) {
console.error('Error loading data:', error)
showErrorMessage('خطا در بارگذاری اطلاعات حواله')
} finally {
loading.value = false
}
}
const isWarrantyRequired = (row: Row) => {
// Always show warranty allocation if count > 0, regardless of settings
return row.count > 0
}
const getWarrantyLines = (row: Row) => {
return row.warrantyLines || []
}
const ensureWarrantiesLoaded = async (rowIndex: number) => {
const row = item.value.rows[rowIndex]
if (row.warrantiesLoaded || row.loadingWarranties) return
await loadAvailableWarranties(rowIndex)
}
const loadAvailableWarranties = async (rowIndex: number) => {
const row = item.value.rows[rowIndex]
const commodity = row.commodity
if (!commodity?.id) return
try {
row.loadingWarranties = true
const res = await axios.get('/api/plugins/warranty/serials', {
params: { status: 'available', commodity_id: commodity.id }
})
const list = Array.isArray(res.data) ? res.data : []
row.availableWarranties = list
.filter((x: any) => x.status === 'available' && x.commodity?.id === commodity.id)
.map((x: any) => ({ label: `${x.serialNumber}`, serialNumber: x.serialNumber }))
row.warrantiesLoaded = true
} catch {
row.availableWarranties = []
row.warrantiesLoaded = true
} finally {
row.loadingWarranties = false
}
}
const filteredWarranties = (rowIndex: number, lineIndex: number) => {
const row = item.value.rows[rowIndex]
const selected = new Set(
row.warrantyLines
?.map((ln, idx) => (idx === lineIndex ? null : (ln.warrantySerial || null)))
.filter((v: any) => !!v) || []
)
return (row.availableWarranties || []).filter(w => !selected.has(w.serialNumber))
}
const onWarrantyBlur = async (rowIndex: number, lineIndex: number) => {
const row = item.value.rows[rowIndex]
const line = row.warrantyLines?.[lineIndex]
if (!line || !line.warrantySerial) return
try {
line.searching = true
const response = await axios.get('/api/plugins/warranty/serials', {
params: {
serialNumber: line.warrantySerial,
status: 'available'
}
})
const warranties = Array.isArray(response.data) ? response.data : []
const warranty = warranties.find((w: any) => w.serialNumber === line.warrantySerial)
if (warranty) {
line.warrantyError = ''
if (warrantySettings.value.matchWarrantyToSerial && !line.serialNumber) {
line.serialNumber = warranty.serialNumber
}
} else {
line.warrantyError = 'گارانتی یافت نشد یا در دسترس نیست'
}
} catch (error) {
line.warrantyError = 'خطا در بررسی گارانتی'
} finally {
line.searching = false
}
}
const searchWarranty = (rowIndex: number, lineIndex: number) => {
currentSearchContext.value = { rowIndex, lineIndex }
showWarrantySearch.value = true
warrantySearchQuery.value = ''
warrantySearchResults.value = []
}
const searchWarranties = async () => {
if (!warrantySearchQuery.value) {
warrantySearchResults.value = []
return
}
try {
const response = await axios.get('/api/plugins/warranty/serials', {
params: {
search: warrantySearchQuery.value,
status: 'available'
}
})
warrantySearchResults.value = Array.isArray(response.data) ? response.data : []
} catch (error) {
warrantySearchResults.value = []
}
}
const selectWarranty = (warranty: any) => {
if (!currentSearchContext.value) return
const { rowIndex, lineIndex } = currentSearchContext.value
const row = item.value.rows[rowIndex]
const line = row.warrantyLines?.[lineIndex]
if (line) {
line.warrantySerial = warranty.serialNumber
line.warrantyError = ''
if (warrantySettings.value.matchWarrantyToSerial && !line.serialNumber) {
line.serialNumber = warranty.serialNumber
}
}
showWarrantySearch.value = false
currentSearchContext.value = null
}
const openBarcodeScanner = (rowIndex: number, lineIndex: number) => {
currentBarcodeScannerContext.value = { rowIndex, lineIndex }
showBarcodeScanner.value = true
}
const onBarcodeScanned = (serialNumber: string) => {
if (!currentBarcodeScannerContext.value) return
const { rowIndex, lineIndex } = currentBarcodeScannerContext.value
const row = item.value.rows[rowIndex]
const line = row.warrantyLines?.[lineIndex]
if (line && !line.isAllocated) {
line.serialNumber = serialNumber
line.warrantyError = ''
line.searching = false
// Auto-fill warranty serial if matchWarrantyToSerial is enabled
if (warrantySettings.value.matchWarrantyToSerial && !line.warrantySerial) {
line.warrantySerial = serialNumber
}
// Move to next line if available
if (lineIndex + 1 < (row.warrantyLines?.length || 0)) {
currentBarcodeScannerContext.value = { rowIndex, lineIndex: lineIndex + 1 }
} else {
showBarcodeScanner.value = false
currentBarcodeScannerContext.value = null
}
} else {
showBarcodeScanner.value = false
currentBarcodeScannerContext.value = null
}
}
const canComplete = computed(() => {
if (loading.value || completing.value) return false
// Only check warranty requirements if requireWarrantyOnDelivery is enabled
if (isPluginActive('warranty') && warrantySettings.value.requireWarrantyOnDelivery) {
for (const row of item.value.rows) {
if (isWarrantyRequired(row)) {
for (const line of row.warrantyLines || []) {
if (!line.warrantySerial) {
return false
}
if (line.warrantyError) {
return false
}
}
}
}
}
return true
})
const completeTicket = async () => {
if (!canComplete.value) return
try {
completing.value = true
// Prepare warranty allocation data
const warrantyData = item.value.rows.map((row, rowIndex) => ({
commodityId: row.commodity?.id,
count: row.count,
warrantyLines: row.warrantyLines?.map(line => ({
warrantySerial: line.warrantySerial,
serialNumber: line.serialNumber,
isBeforeAllocated: line.isAllocated
})) || []
})).filter(item => item.warrantyLines.length > 0)
// Call API to complete the process
await axios.post(`/api/storeroom/ticket/complete/${route.params.id}`, {
warrantyAllocations: warrantyData
})
showSuccessDialog.value = true
} catch (error: any) {
console.error('Error completing ticket:', error)
const message = error.response?.data?.message || 'خطا در تکمیل پروسه'
showErrorMessage(message)
} finally {
completing.value = false
}
}
const goBack = () => {
router.push('/acc/storeroom/tickets/list/helper')
}
const showErrorMessage = (message: string) => {
errorMessage.value = message
showError.value = true
}
const formatDate = (dateVal: any) => {
if (!dateVal) return '-'
try {
const d = typeof dateVal === 'string' ? new Date(dateVal) : dateVal
const valid = d instanceof Date && !isNaN(d.getTime())
if (!valid) return '-'
const j = moment(d)
const y = j.jYear()
const m = j.jMonth() + 1
const day = j.jDate()
return `${y}/${String(m).padStart(2, '0')}/${String(day).padStart(2, '0')}`
} catch {
return '-'
}
}
onMounted(() => {
loadData()
})
const isPluginActive = (plugName: string) => {
return plugins.value[plugName] !== undefined
}
</script>
<style scoped>
.v-card {
border-radius: 12px;
}
.v-card-title {
border-bottom: 1px solid #e0e0e0;
}
.lines-wrap {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
.line-row {
display: grid;
grid-template-columns: 60px 1fr 120px 1fr 80px;
gap: 8px;
align-items: center;
padding: 12px;
border-bottom: 1px solid #f0f0f0;
}
.line-row:last-child {
border-bottom: none;
}
.line-row.header {
background-color: #f5f5f5;
font-weight: 600;
font-size: 0.875rem;
color: #666;
}
.col-idx {
text-align: center;
font-weight: 500;
}
.col-serial {
min-width: 0;
}
.col-warranty {
min-width: 0;
}
.col-actions {
display: flex;
justify-content: center;
align-items: center;
}
.no-lines {
padding: 24px;
text-align: center;
color: #666;
font-style: italic;
}
</style>

View file

@ -88,7 +88,7 @@
<v-text-field v-model="items[index].referral" variant="outlined" density="compact" /> <v-text-field v-model="items[index].referral" variant="outlined" density="compact" />
</template> </template>
<template v-slot:expanded-row="{ item, index }"> <template v-slot:expanded-row="{ item, index }" v-if="isPluginActive('warranty')">
<td :colspan="headers.length" class="py-4 px-3"> <td :colspan="headers.length" class="py-4 px-3">
<div class="lines-wrap"> <div class="lines-wrap">
<div class="line-row header"> <div class="line-row header">
@ -105,40 +105,31 @@
<div class="col-serial"> <div class="col-serial">
<v-text-field v-model.trim="items[index].lines[lidx].serialNumber" variant="outlined" <v-text-field v-model.trim="items[index].lines[lidx].serialNumber" variant="outlined"
density="compact" hide-details="auto" placeholder="شماره سریال را وارد یا اسکن کنید" density="compact" hide-details="auto" placeholder="شماره سریال را وارد یا اسکن کنید"
:rules="serialRules" :rules="serialRules" @blur="onSerialBlur(index, lidx)" />
@blur="onSerialBlur(index, lidx)" />
</div> </div>
<div class="col-actions"> <div class="col-actions">
<v-btn icon size="small" color="primary" variant="text" <!-- <v-btn icon size="small" color="primary" variant="text"
@click="openScanner({ mode: 'serial', itemIndex: index, lineIndex: lidx })"> @click="scanner.open = true, scanner.mode = 'serial', scanner.itemIndex = index, scanner.lineIndex = lidx">
<v-icon>mdi-barcode-scan</v-icon> <v-icon>mdi-barcode-scan</v-icon>
</v-btn> </v-btn> -->
</div> </div>
<div class="col-warranty"> <div class="col-warranty">
<v-autocomplete <v-autocomplete v-model="items[index].lines[lidx].warrantySerial"
v-model="items[index].lines[lidx].warrantySerial" :items="filteredWarranties(index, lidx)" item-title="label" item-value="serialNumber"
:items="filteredWarranties(index, lidx)" variant="outlined" density="compact" hide-details="auto" clearable
item-title="label"
item-value="serialNumber"
variant="outlined"
density="compact"
hide-details="auto"
clearable
:loading="items[index].loadingWarranties" :loading="items[index].loadingWarranties"
:no-data-text="items[index].loadingWarranties ? 'در حال بارگذاری...' : (items[index].warrantiesLoaded ? 'موردی یافت نشد' : 'برای مشاهده لیست، کلیک کنید')" :no-data-text="items[index].loadingWarranties ? 'در حال بارگذاری...' : (items[index].warrantiesLoaded ? 'موردی یافت نشد' : 'برای مشاهده لیست، کلیک کنید')"
:filter="(i: any, q: any) => String(i.label || '').toLowerCase().includes(String(q || '').toLowerCase())" :filter="(i: any, q: any) => String(i.label || '').toLowerCase().includes(String(q || '').toLowerCase())"
placeholder="انتخاب/وارد کردن کد گارانتی" placeholder="انتخاب/وارد کردن کد گارانتی" :rules="warrantyRules"
:rules="warrantyRules"
@update:menu="val => { if (val) ensureWarrantiesLoaded(index) }" @update:menu="val => { if (val) ensureWarrantiesLoaded(index) }"
@update:search="ensureWarrantiesLoaded(index)" @update:search="ensureWarrantiesLoaded(index)" />
/>
</div> </div>
<div class="col-actions"> <div class="col-actions">
<v-btn icon size="small" color="secondary" variant="text" <v-btn icon size="small" color="secondary" variant="text"
@click="openScanner({ mode: 'warranty', itemIndex: index, lineIndex: lidx })"> @click="scanner.open = true, scanner.mode = 'serial', scanner.itemIndex = index, scanner.lineIndex = lidx">
<v-icon>mdi-barcode-scan</v-icon> <v-icon>mdi-barcode-scan</v-icon>
</v-btn> </v-btn>
</div> </div>
@ -155,39 +146,7 @@
</v-row> </v-row>
</v-container> </v-container>
<v-dialog v-model="scanner.open" :max-width="isMobile ? '95vw' : 560" persistent> <BarcodeScanner v-model="scanner.open" @detected="handleBarcodeScan" />
<v-card class="qr-card">
<v-card-title class="qr-title">
<v-icon left color="primary">mdi-qrcode-scan</v-icon>
اسکن کد {{ scanner.mode === 'serial' ? 'سریال کالا' : 'گارانتی' }}
</v-card-title>
<v-card-text>
<div class="qr-wrap">
<div :id="readerId" ref="readerWrap" class="qr-reader"></div>
</div>
<div class="qr-status">
<v-alert v-if="scanner.error" type="error" variant="tonal" density="comfortable">
{{ scanner.error }}
</v-alert>
<v-progress-circular v-if="loadingScan" indeterminate size="28" class="mt-3" color="primary"
style="display: block; margin: 0 auto" />
<div class="qr-supported-formats mt-3 text-center text-caption text-grey">
پشتیبانی از QR Code، Code 128، Code 39، EAN-13، UPC-A، Data Matrix
</div>
</div>
</v-card-text>
<v-card-actions class="qr-actions">
<v-btn :disabled="!scanner.ready || 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="snackbar.show" :color="snackbar.color" :timeout="3000" location="bottom"> <v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000" location="bottom">
{{ snackbar.message }} {{ snackbar.message }}
@ -202,7 +161,7 @@ import { ref, reactive, onMounted, nextTick, computed, watch } from 'vue'
import axios from 'axios' import axios from 'axios'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import Hdatepicker from '@/components/forms/Hdatepicker.vue' import Hdatepicker from '@/components/forms/Hdatepicker.vue'
import { Html5Qrcode, Html5QrcodeSupportedFormats, Html5QrcodeScannerState } from 'html5-qrcode' import BarcodeScanner from '@/components/widgets/BarcodeScanner.vue'
interface TransferType { id: number; name: string } interface TransferType { id: number; name: string }
interface Person { des: string; mobile: string } interface Person { des: string; mobile: string }
@ -281,10 +240,6 @@ const warrantySettings = ref({ requireWarrantyOnDelivery: false, matchWarrantyTo
const serialRules = computed(() => warrantySettings.value.requireWarrantyOnDelivery ? [rules.required] : []) const serialRules = computed(() => warrantySettings.value.requireWarrantyOnDelivery ? [rules.required] : [])
const warrantyRules = computed(() => warrantySettings.value.requireWarrantyOnDelivery ? [rules.required] : []) const warrantyRules = computed(() => warrantySettings.value.requireWarrantyOnDelivery ? [rules.required] : [])
const readerId = 'qr-reader-' + Math.random().toString(36).slice(2)
const readerWrap = ref<HTMLElement | null>(null)
let qr: Html5Qrcode | null = null
const loadingScan = ref(false)
const scanner = reactive({ const scanner = reactive({
open: false, open: false,
ready: false, ready: false,
@ -297,122 +252,34 @@ const scanner = reactive({
}) })
const isMobile = computed(() => window.innerWidth <= 768) const isMobile = computed(() => window.innerWidth <= 768)
const openScanner = async (opt: { mode: 'serial' | 'warranty'; itemIndex: number; lineIndex: number }) => { const handleBarcodeScan = async (text: string) => {
scanner.open = true if (scanner.mode === 'serial') {
scanner.mode = opt.mode items.value[scanner.itemIndex].lines[scanner.lineIndex].serialNumber = text
scanner.itemIndex = opt.itemIndex if (!warrantySettings.value.matchWarrantyToSerial) return
scanner.lineIndex = opt.lineIndex const row = items.value[scanner.itemIndex]
scanner.error = '' const ln = row.lines[scanner.lineIndex]
scanner.ready = false try {
await nextTick() const found = (row.availableWarranties || []).find(w => String(w.serialNumber).trim() === ln.serialNumber)
try { if (found) {
loadingScan.value = true ln.warrantySerial = found.serialNumber
if (!qr) { return
qr = new Html5Qrcode(readerId, { }
verbose: false, if (!row.warrantiesLoaded) {
formatsToSupport: [ await loadAvailableWarranties(scanner.itemIndex)
Html5QrcodeSupportedFormats.QR_CODE, }
Html5QrcodeSupportedFormats.CODE_128, const foundAfter = (row.availableWarranties || []).find(w => String(w.serialNumber).trim() === ln.serialNumber)
Html5QrcodeSupportedFormats.CODE_39, if (foundAfter) {
Html5QrcodeSupportedFormats.EAN_13, ln.warrantySerial = foundAfter.serialNumber
Html5QrcodeSupportedFormats.UPC_A, } else {
Html5QrcodeSupportedFormats.DATA_MATRIX showSnack('کد گارانتی متناظر با سریال یافت نشد', 'warning')
], }
experimentalFeatures: { useBarCodeDetectorIfSupported: true } } catch {
})
} else {
if (qr.getState() === Html5QrcodeScannerState.SCANNING) await qr.stop()
await qr.clear()
} }
const devices = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput') } else {
if (!devices.length) throw new Error('دوربین یافت نشد') const ok = await validateWarranty(text, items.value[scanner.itemIndex].commodity)
scanner.cameras = devices if (!ok.valid) { showSnack(ok.message || 'گارانتی معتبر نیست', 'error'); return }
scanner.currentDeviceId = items.value[scanner.itemIndex].lines[scanner.lineIndex].warrantySerial = text
devices.find(d => /back|rear|environment/i.test(d.label))?.deviceId || devices[0].deviceId showSnack('گارانتی ثبت شد', 'success')
const w = Math.min(520, (readerWrap.value?.clientWidth || 480))
const size = Math.max(220, Math.round(w * 0.66))
await qr.start(
{ deviceId: { exact: scanner.currentDeviceId } },
{ fps: 12, qrbox: { width: size, height: size }, aspectRatio: 1.333 },
async (decodedText: string) => {
const text = decodedText.trim()
if (!text) return
if (scanner.mode === 'serial') {
items.value[scanner.itemIndex].lines[scanner.lineIndex].serialNumber = text
showSnack('سریال ثبت شد', 'success')
await closeScanner()
} else {
const ok = await validateWarranty(text, items.value[scanner.itemIndex].commodity)
if (!ok.valid) { showSnack(ok.message || 'گارانتی معتبر نیست', 'error'); return }
items.value[scanner.itemIndex].lines[scanner.lineIndex].warrantySerial = text
showSnack('گارانتی ثبت شد', 'success')
await closeScanner()
}
},
() => { }
)
scanner.ready = true
} catch (e: any) {
scanner.error = e?.message || 'خطا در راه‌اندازی دوربین'
} finally {
loadingScan.value = false
}
}
const switchCamera = async () => {
if (!qr || !scanner.cameras.length || !scanner.ready) return
try {
loadingScan.value = true
const idx = scanner.cameras.findIndex(c => c.deviceId === scanner.currentDeviceId)
const next = scanner.cameras[(idx + 1) % scanner.cameras.length]
if (qr.getState() === Html5QrcodeScannerState.SCANNING) await qr.stop()
await qr.clear()
scanner.currentDeviceId = next.deviceId
const w = Math.min(520, (readerWrap.value?.clientWidth || 480))
const size = Math.max(220, Math.round(w * 0.66))
await qr.start(
{ deviceId: { exact: scanner.currentDeviceId } },
{ fps: 12, qrbox: { width: size, height: size }, aspectRatio: 1.333 },
async (decodedText: string) => {
const text = decodedText.trim()
if (!text) return
if (scanner.mode === 'serial') {
items.value[scanner.itemIndex].lines[scanner.lineIndex].serialNumber = text
showSnack('سریال ثبت شد', 'success')
await closeScanner()
} else {
const ok = await validateWarranty(text, items.value[scanner.itemIndex].commodity)
if (!ok.valid) { showSnack(ok.message || 'گارانتی معتبر نیست', 'error'); return }
items.value[scanner.itemIndex].lines[scanner.lineIndex].warrantySerial = text
showSnack('گارانتی ثبت شد', 'success')
await closeScanner()
}
},
() => { }
)
} catch (e: any) {
scanner.error = e?.message || 'خطا در تغییر دوربین'
} finally {
loadingScan.value = false
}
}
const closeScanner = async () => {
try {
loadingScan.value = true
if (qr) {
if (qr.getState() === Html5QrcodeScannerState.SCANNING) await qr.stop()
await qr.clear()
}
} finally {
scanner.open = false
scanner.ready = false
scanner.error = ''
loadingScan.value = false
} }
} }
@ -478,18 +345,8 @@ const submit = async () => {
items.value.forEach((row) => { items.value.forEach((row) => {
totalCount += Number(row.ticketCount || 0) totalCount += Number(row.ticketCount || 0)
if (warrantySettings.value.requireWarrantyOnDelivery) {
for (let i = 0; i < row.ticketCount; i++) {
const ln = row.lines[i]
if (!ln || !ln.serialNumber || !ln.warrantySerial) {
errors.push('پر کردن فیلد های گارانتی و سریال کالا الزامی است')
break
}
}
}
}) })
if (totalCount === 0) errors.push('هیچ کالایی برای خروج انتخاب نشده است') if (totalCount === 0) errors.push('هیچ کالایی برای خروج انتخاب نشده است')
if (errors.length) { if (errors.length) {
@ -507,8 +364,7 @@ const submit = async () => {
serialLines: r.lines.slice(0, r.ticketCount).map(l => ({ serial: l.serialNumber, warranty: l.warrantySerial })) serialLines: r.lines.slice(0, r.ticketCount).map(l => ({ serial: l.serialNumber, warranty: l.warrantySerial }))
})) }))
const requireWarrantySerial = warrantySettings.value.requireWarrantyOnDelivery || payloadItems.some(it => (it.serialLines || []).length > 0) const payloadTicket = { ...ticket.value, requireWarrantySerial: false }
const payloadTicket = { ...ticket.value, requireWarrantySerial }
const response = await axios.post('/api/storeroom/ticket/insert', { const response = await axios.post('/api/storeroom/ticket/insert', {
doc: doc.value, doc: doc.value,
@ -591,8 +447,10 @@ const loadData = async () => {
ticket.value.transferType = transferTypesResponse.data[0] ticket.value.transferType = transferTypesResponse.data[0]
plugins.value = pluginsResponse.data plugins.value = pluginsResponse.data
warrantySettings.value.requireWarrantyOnDelivery = !!(warrantyResp?.data?.requireWarrantyOnDelivery) if (isPluginActive('warranty')) {
warrantySettings.value.matchWarrantyToSerial = !!(warrantyResp?.data?.matchWarrantyToSerial) warrantySettings.value.requireWarrantyOnDelivery = !!(warrantyResp?.data?.requireWarrantyOnDelivery)
warrantySettings.value.matchWarrantyToSerial = !!(warrantyResp?.data?.matchWarrantyToSerial)
}
} catch { } catch {
showSnack('خطا در بارگذاری داده‌ها', 'error') showSnack('خطا در بارگذاری داده‌ها', 'error')
} finally { } finally {

View file

@ -34,15 +34,15 @@
<v-icon start>mdi-file-export</v-icon> <v-icon start>mdi-file-export</v-icon>
حوالههای خروج حوالههای خروج
</v-tab> </v-tab>
<v-tab value="input"> <v-tab v-if="permissions.store" value="input">
<v-icon start>mdi-file-import</v-icon> <v-icon start>mdi-file-import</v-icon>
حوالههای ورود حوالههای ورود
</v-tab> </v-tab>
<v-tab value="transfer"> <v-tab v-if="permissions.store" value="transfer">
<v-icon start>mdi-swap-horizontal</v-icon> <v-icon start>mdi-swap-horizontal</v-icon>
حوالههای انتقال حوالههای انتقال
</v-tab> </v-tab>
<v-tab value="waste"> <v-tab v-if="permissions.store" value="waste">
<v-icon start>mdi-delete-empty</v-icon> <v-icon start>mdi-delete-empty</v-icon>
ضایعات ضایعات
</v-tab> </v-tab>
@ -55,7 +55,7 @@
density="compact" hide-details class="mb-1"></v-text-field> 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-tabs v-if="business.requireTwoStepApproval" v-model="outputSubTab" color="primary" density="comfortable" class="mb-2">
<v-tab value="approved">تایید شده</v-tab> <v-tab value="approved">تایید شده</v-tab>
<v-tab value="pending">در انتظار تایید</v-tab> <v-tab v-if="permissions.store" value="pending">در انتظار تایید</v-tab>
</v-tabs> </v-tabs>
<v-data-table :headers="visibleHeaders" :items="displayOutputItems" :search="outputSearchValue" :loading="loading" <v-data-table :headers="visibleHeaders" :items="displayOutputItems" :search="outputSearchValue" :loading="loading"
@ -75,6 +75,12 @@
</template> </template>
<v-list-item-title>مشاهده</v-list-item-title> <v-list-item-title>مشاهده</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item v-if="item.approved" @click="printTicket(item.code)">
<template v-slot:prepend>
<v-icon color="primary">mdi-printer</v-icon>
</template>
<v-list-item-title>چاپ PDF</v-list-item-title>
</v-list-item>
<v-list-item v-if="canShowApprovalButton(item)" @click="approveTicket(item.code)"> <v-list-item v-if="canShowApprovalButton(item)" @click="approveTicket(item.code)">
<template v-slot:prepend> <template v-slot:prepend>
<v-icon color="primary">mdi-check-decagram</v-icon> <v-icon color="primary">mdi-check-decagram</v-icon>
@ -87,12 +93,18 @@
</template> </template>
<v-list-item-title>لغو تایید حواله</v-list-item-title> <v-list-item-title>لغو تایید حواله</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item @click="deleteTicket('output', item.code)"> <v-list-item v-if="permissions.store" @click="deleteTicket('output', item.code)">
<template v-slot:prepend> <template v-slot:prepend>
<v-icon color="error">mdi-delete</v-icon> <v-icon color="error">mdi-delete</v-icon>
</template> </template>
<v-list-item-title>حذف</v-list-item-title> <v-list-item-title>حذف</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item v-if="permissions.store && (!item.completed || currentUser.owner) && item.approved" @click="completeProcess('output', 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> </v-list>
</v-menu> </v-menu>
</td> </td>
@ -356,7 +368,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from "axios"; import axios from "axios";
import Swal from 'sweetalert2';
const router = useRouter()
interface Ticket { interface Ticket {
code: string; code: string;
@ -377,6 +393,7 @@ interface Ticket {
email: string; email: string;
id: number; id: number;
}; };
completed?: boolean;
} }
interface Header { interface Header {
@ -388,6 +405,12 @@ interface Header {
visible: boolean; visible: boolean;
} }
interface Permissions {
store?: boolean;
storehelper?: boolean;
[key: string]: any;
}
const loading = ref(false); const loading = ref(false);
const inputItems = ref<Ticket[]>([]); const inputItems = ref<Ticket[]>([]);
const outputItems = ref<Ticket[]>([]); const outputItems = ref<Ticket[]>([]);
@ -398,6 +421,7 @@ const outputSearchValue = ref('');
const transferSearchValue = ref(''); const transferSearchValue = ref('');
const wasteSearchValue = ref(''); const wasteSearchValue = ref('');
const activeTab = ref('output'); const activeTab = ref('output');
const permissions = ref<Permissions>({});
const inputSubTab = ref<'approved' | 'pending'>('approved'); const inputSubTab = ref<'approved' | 'pending'>('approved');
const outputSubTab = ref<'approved' | 'pending'>('approved'); const outputSubTab = ref<'approved' | 'pending'>('approved');
const transferSubTab = ref<'approved' | 'pending'>('approved'); const transferSubTab = ref<'approved' | 'pending'>('approved');
@ -454,6 +478,25 @@ const isColumnVisible = (key: string) => {
const LOCAL_STORAGE_KEY = 'hesabix_storeroom_tickets_table_columns'; const LOCAL_STORAGE_KEY = 'hesabix_storeroom_tickets_table_columns';
const printTicket = async (code: string) => {
try {
const response = await axios.post('/api/storeroom/print/ticket', {
code: code,
type: 'output'
})
window.open(`${import.meta.env.VITE_API_URL}/front/print/${response.data.id}`, '_blank', 'noreferrer')
} catch (error: any) {
if (error?.response?.data?.message) {
Swal.fire({
text: error?.response?.data?.message,
icon: 'warning',
confirmButtonText: 'قبول'
});
}
console.error('Error printing ticket:', error)
}
}
const loadColumnSettings = () => { const loadColumnSettings = () => {
const savedSettings = localStorage.getItem(LOCAL_STORAGE_KEY); const savedSettings = localStorage.getItem(LOCAL_STORAGE_KEY);
if (savedSettings) { if (savedSettings) {
@ -524,6 +567,16 @@ const loadCurrentUser = async () => {
} }
}; };
const loadPermissions = async () => {
try {
const response = await axios.post('/api/business/get/user/permissions');
permissions.value = response.data || {};
} catch (error: any) {
console.error('Error loading permissions:', error);
permissions.value = {};
}
};
const loadData = async () => { const loadData = async () => {
loading.value = true; loading.value = true;
try { try {
@ -667,10 +720,15 @@ const confirmDelete = async () => {
} }
}; };
const completeProcess = (type: 'input' | 'output' | 'transfer' | 'waste', code: string) => {
router.push(`/acc/storeroom/ticket/complete/${code}`);
};
onMounted(() => { onMounted(() => {
loadColumnSettings(); loadColumnSettings();
loadBusinessInfo(); loadBusinessInfo();
loadCurrentUser(); loadCurrentUser();
loadPermissions();
loadData(); loadData();
}); });
</script> </script>

View file

@ -0,0 +1,504 @@
<template>
<v-toolbar color="toolbar" title="کمک انباردار">
<template v-slot:prepend>
<v-tooltip text="بازگشت" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" @click="$router.back()" class="d-none d-sm-flex" variant="text"
icon="mdi-arrow-right" />
</template>
</v-tooltip>
</template>
<v-spacer />
<v-slide-group show-arrows>
<v-slide-group-item>
<v-tooltip text="تنظیمات ستون‌ها" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-table-cog" color="primary" @click="showColumnDialog = true" />
</template>
</v-tooltip>
</v-slide-group-item>
</v-slide-group>
</v-toolbar>
<v-tabs v-model="activeTab" color="primary" grow class="mb-3">
<v-tab value="output">
<v-icon start>mdi-file-export</v-icon>
حوالههای خروج
</v-tab>
</v-tabs>
<v-window v-model="activeTab">
<!-- تب حوالههای خروج -->
<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-tabs>
<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 }">
<tr>
<td v-if="isColumnVisible('operation')" class="text-center">
<v-menu>
<template v-slot:activator="{ props }">
<v-btn variant="text" size="small" color="error" icon="mdi-menu" v-bind="props" />
</template>
<v-list>
<v-list-item :to="'/acc/storeroom/ticket/view/' + item.code">
<template v-slot:prepend>
<v-icon color="success">mdi-eye</v-icon>
</template>
<v-list-item-title>مشاهده</v-list-item-title>
</v-list-item>
<v-list-item v-if="item.approved" @click="printTicket(item.code)">
<template v-slot:prepend>
<v-icon color="primary">mdi-printer</v-icon>
</template>
<v-list-item-title>چاپ PDF</v-list-item-title>
</v-list-item>
<v-list-item v-if="(permissions.store || permissions.storehelper) && (!item.completed || currentUser.owner) && item.approved" @click="completeProcess('output', 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>
</v-menu>
</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('completedStatus')" class="text-center">
<v-chip size="small" :color="getCompletedStatusColor(item)">
{{ getCompletedStatusText(item) }}
</v-chip>
</td>
<td v-if="isColumnVisible('completedBy')" class="text-center">
{{ item.completedBy?.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>
</template>
</v-data-table>
</v-window-item>
</v-window>
<v-dialog v-model="showColumnDialog" max-width="500">
<v-card>
<v-toolbar color="toolbar" title="مدیریت ستون‌ها">
<v-spacer></v-spacer>
<v-btn icon @click="showColumnDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar>
<v-card-text>
<v-row>
<v-col v-for="header in allHeaders" :key="header.key" cols="12" sm="6">
<v-checkbox v-model="header.visible" :label="header.title" @change="updateColumnVisibility" hide-details />
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDialog.show" max-width="400">
<v-card>
<v-card-title class="text-h6">
تأیید حذف
</v-card-title>
<v-card-text>
آیا برای حذف حواله مطمئن هستید؟
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="deleteDialog.show = false">خیر</v-btn>
<v-btn color="error" variant="text" @click="confirmDelete">بله</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000" location="bottom">
{{ snackbar.message }}
<template v-slot:actions>
<v-btn variant="text" @click="snackbar.show = false">
بستن
</v-btn>
</template>
</v-snackbar>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from "axios";
import Swal from 'sweetalert2';
const router = useRouter()
interface Ticket {
code: string;
date: string;
doc: {
code: string;
preview?: boolean;
approved?: boolean;
};
person: {
nikename: string;
};
des: string;
preview?: boolean;
approved?: boolean;
completedBy?: {
fullName: string;
email: string;
id: number;
};
completed?: boolean;
}
interface Header {
title: string;
key: string;
align?: 'start' | 'center' | 'end';
sortable: boolean;
width: number;
visible: boolean;
}
interface Permissions {
store?: boolean;
storehelper?: boolean;
[key: string]: any;
}
const loading = ref(false);
const inputItems = ref<Ticket[]>([]);
const outputItems = ref<Ticket[]>([]);
const transferItems = ref<Ticket[]>([]);
const wasteItems = ref<Ticket[]>([]);
const inputSearchValue = ref('');
const outputSearchValue = ref('');
const transferSearchValue = ref('');
const wasteSearchValue = ref('');
const activeTab = ref('output');
const permissions = ref<Permissions>({});
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,
approvers: { warehouseTransfer: null }
});
const currentUser = ref({ email: '', owner: false });
const deleteDialog = ref({
show: false,
type: null as 'input' | 'output' | 'transfer' | 'waste' | null,
code: null as string | null
});
const snackbar = ref({
show: false,
message: '',
color: 'primary'
});
const allHeaders = ref<Header[]>([
{ 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: "completedStatus", align: 'center' as const, sortable: true, width: 150, visible: true },
{ title: "ارسال کننده", key: "completedBy", 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) => {
if ((header.key === 'completedStatus' || header.key === 'completedBy') && !business.value.requireTwoStepApproval) {
return false;
}
return header.visible;
}) as any;
});
const isColumnVisible = (key: string) => {
const header = allHeaders.value.find((header: Header) => header.key === key);
if (!header) return false;
if ((key === 'completedStatus' || key === 'completedBy') && !business.value.requireTwoStepApproval) {
return false;
}
return header.visible;
};
const LOCAL_STORAGE_KEY = 'hesabix_storeroom_tickets_table_columns';
const printTicket = async (code: string) => {
try {
const response = await axios.post('/api/storeroom/print/ticket', {
code: code,
type: 'output'
})
window.open(`${import.meta.env.VITE_API_URL}/front/print/${response.data.id}`, '_blank', 'noreferrer')
} catch (error: any) {
if (error?.response?.data?.message) {
Swal.fire({
text: error?.response?.data?.message,
icon: 'warning',
confirmButtonText: 'قبول'
});
}
console.error('Error printing ticket:', error)
}
}
const loadColumnSettings = () => {
const savedSettings = localStorage.getItem(LOCAL_STORAGE_KEY);
if (savedSettings) {
const visibleColumns = JSON.parse(savedSettings);
allHeaders.value.forEach(header => {
header.visible = visibleColumns.includes(header.key);
});
}
};
const updateColumnVisibility = () => {
const visibleColumns = allHeaders.value
.filter(header => header.visible)
.map(header => header.key);
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(visibleColumns));
};
const formatNumber = (value: string | number) => {
if (!value) return '0';
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, approvers: { warehouseTransfer: null } };
} catch (error: any) {
console.error('Error loading business info:', error);
business.value = { requireTwoStepApproval: false, approvers: { warehouseTransfer: 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 loadPermissions = async () => {
try {
const response = await axios.post('/api/business/get/user/permissions');
permissions.value = response.data || {};
} catch (error: any) {
console.error('Error loading permissions:', error);
permissions.value = {};
}
};
const loadData = async () => {
loading.value = true;
try {
const [outputResponse] = await Promise.all([
axios.post('/api/storeroom/tickets/list/output'),
]);
outputItems.value = (outputResponse.data || []);
} catch (error: any) {
console.error('Error loading data:', error);
snackbar.value = {
show: true,
message: 'خطا در بارگذاری داده‌ها: ' + error.message,
color: 'error'
};
} finally {
loading.value = false;
}
};
const checkApprover = () => {
return business.value.requireTwoStepApproval && (business.value.approvers.warehouseTransfer === currentUser.value.email || currentUser.value.owner === true);
};
const canShowApprovalButton = (item: Ticket) => {
if (!checkApprover()) return false;
if (item?.approved) return false;
return true;
};
const canShowUnapproveButton = (item: Ticket) => {
return !canShowApprovalButton(item) && checkApprover();
};
const approveTicket = async (code: string) => {
try {
loading.value = true;
const response = await axios.post(`/api/approval/approve/storeroom/${code}`);
await loadData();
if (response.data.success) {
snackbar.value = { show: true, message: 'حواله تایید شد', color: 'success' };
} else {
snackbar.value = { show: true, message: response.data.message, color: 'error' };
}
} catch (error: any) {
snackbar.value = { show: true, message: 'خطا در تایید حواله: ' + (error.response?.data?.message || error.message), color: 'error' };
} finally {
loading.value = false;
}
};
const unapproveTicket = async (code: string) => {
try {
loading.value = true;
const response = await axios.post(`/api/approval/unapprove/storeroom/${code}`);
await loadData();
if (response.data.success) {
snackbar.value = { show: true, message: 'حواله لغو تایید شد', color: 'success' };
} else {
snackbar.value = { show: true, message: response.data.message, color: 'error' };
}
} catch (error: any) {
snackbar.value = { show: true, message: 'خطا در لغو تایید حواله: ' + (error.response?.data?.message || error.message), color: 'error' };
} finally {
loading.value = false;
}
};
const getCompletedStatusText = (item: Ticket) => {
if (item?.completed) return 'ارسال شده';
return 'در انتظار ارسال';
};
const getCompletedStatusColor = (item: Ticket) => {
if (item?.completed) return 'success';
return 'warning';
};
const deleteTicket = (type: 'input' | 'output' | 'transfer' | 'waste', code: string) => {
deleteDialog.value = {
show: true,
type,
code
};
};
const confirmDelete = async () => {
if (!deleteDialog.value?.type || !deleteDialog.value?.code) return;
const { type, code } = deleteDialog.value;
deleteDialog.value.show = false;
try {
loading.value = true;
await axios.post('/api/storeroom/ticket/remove/' + code);
if (type === 'input') {
inputItems.value = inputItems.value.filter(item => item.code !== code);
} else if (type === 'output') {
outputItems.value = outputItems.value.filter(item => item.code !== code);
} else if (type === 'transfer') {
transferItems.value = transferItems.value.filter(item => item.code !== code);
} else {
wasteItems.value = wasteItems.value.filter(item => item.code !== code);
}
snackbar.value = {
show: true,
message: 'حواله انبار حذف شد.',
color: 'success'
};
} catch (error: any) {
console.error('Error deleting ticket:', error);
snackbar.value = {
show: true,
message: 'خطا در حذف حواله: ' + error.message,
color: 'error'
};
} finally {
loading.value = false;
deleteDialog.value = {
show: false,
type: null,
code: null
};
}
};
const completeProcess = (type: 'input' | 'output' | 'transfer' | 'waste', code: string) => {
router.push(`/acc/storeroom/ticket/complete/${code}`);
};
onMounted(() => {
loadColumnSettings();
loadBusinessInfo();
loadCurrentUser();
loadPermissions();
loadData();
});
</script>
<style scoped>
.v-data-table {
direction: rtl;
width: 100%;
overflow-x: auto;
}
:deep(.v-data-table-header th) {
text-align: center !important;
}
:deep(.v-data-table__wrapper table td) {
text-align: center !important;
}
</style>

View file

@ -106,10 +106,11 @@ const headers = [
const loadData = async () => { const loadData = async () => {
loading.value = true loading.value = true
try { try {
const [ticketResponse, businessResponse, warrantyResponse] = await Promise.all([ const [ticketResponse, businessResponse, warrantyResponse, pluginsResponse] = await Promise.all([
axios.post(`/api/storeroom/tickets/info/${router.currentRoute.value.params.id}`), axios.post(`/api/storeroom/tickets/info/${router.currentRoute.value.params.id}`),
axios.post(`/api/business/get/info/${localStorage.getItem('activeBid')}`), axios.post(`/api/business/get/info/${localStorage.getItem('activeBid')}`),
axios.get(`/api/plugins/warranty/serials/by-storeroom-ticket/${router.currentRoute.value.params.id}`) axios.get(`/api/plugins/warranty/serials/by-storeroom-ticket/${router.currentRoute.value.params.id}`),
axios.post('/api/plugin/get/actives')
]) ])
item.value.ticket = ticketResponse.data.ticket item.value.ticket = ticketResponse.data.ticket
@ -122,6 +123,8 @@ const loadData = async () => {
// warranty data // warranty data
warrantySerials.value = (warrantyResponse.data && warrantyResponse.data.items) ? warrantyResponse.data.items : [] warrantySerials.value = (warrantyResponse.data && warrantyResponse.data.items) ? warrantyResponse.data.items : []
ticketActivationCode.value = warrantyResponse.data ? (warrantyResponse.data.ticketActivationCode || null) : null ticketActivationCode.value = warrantyResponse.data ? (warrantyResponse.data.ticketActivationCode || null) : null
plugins.value = pluginsResponse.data
} catch (error) { } catch (error) {
console.error('Error loading data:', error) console.error('Error loading data:', error)
} finally { } finally {
@ -136,7 +139,7 @@ const printInvoice = async () => {
type: item.value.ticket.type type: item.value.ticket.type
}) })
window.open(`${import.meta.env.VITE_API_URL}/front/print/${response.data.id}`, '_blank', 'noreferrer') window.open(`${import.meta.env.VITE_API_URL}/front/print/${response.data.id}`, '_blank', 'noreferrer')
} catch (error) { } catch (error: any) {
if (error?.response?.data?.message) { if (error?.response?.data?.message) {
Swal.fire({ Swal.fire({
text: error?.response?.data?.message, text: error?.response?.data?.message,
@ -333,6 +336,10 @@ const previewAttachment = async (att: any) => {
} }
} }
const plugins = ref<any>({})
const isPluginActive = (plugName: string) => plugins.value[plugName] !== undefined
onMounted(() => { onMounted(() => {
loadData() loadData()
loadAttachments() loadAttachments()
@ -502,9 +509,9 @@ onMounted(() => {
</v-card> </v-card>
<!-- Warranty Section --> <!-- Warranty Section -->
<v-card variant="outlined" class="mt-4"> <v-card variant="outlined" class="mt-4" v-if="isPluginActive('warranty')">
<v-card-title class="text-subtitle-1 font-weight-bold"> <v-card-title class="text-subtitle-1 font-weight-bold">
<v-icon start>mdi-shield-check</v-icon> <v-icon start>mdi-shield-check</v-icon>
گارانتیهای حواله و کد فعالسازی گارانتیهای حواله و کد فعالسازی
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>

File diff suppressed because it is too large Load diff