مطالب
تهیه خروجی PDF و اکسل از حاصل جستجوی پویای jqGrid به کمک PDF Report
پیشنیازها
- صفحه بندی و مرتب سازی خودکار اطلاعات به کمک jqGrid در ASP.NET MVC
- فعال سازی و پردازش جستجوی پویای jqGrid در ASP.NET MVC
- سفارشی سازی عناصر صفحات پویای افزودن و ویرایش رکوردهای jqGrid در ASP.NET MVC
- آشنایی با کتابخانه‌ی PDF Report


اضافه کردن دکمه‌ی خروجی به jqGrid

برای تهیه خروجی از jqGrid نیاز است بدانیم، اکنون در چه صفحه‌ای از اطلاعات قرار داریم؟ بر روی چه ستونی، مرتب سازی صورت گرفته‌است؟ بر روی کدام فیلدها با چه مقادیری جستجو انجام شده‌است؟ تا ... بتوانیم بر این مبنا، منبع داده‌ی موجود را فیلتر کرده و لیست نهایی را تبدیل به گزارش کنیم. گزارشی که دقیقا با اطلاعاتی که کاربر در صفحه مشاهده می‌کند، تطابق داشته باشد.
خوشبختانه تمام این سؤالات توسط متد توکار excelExport در سمت سرور قابل دریافت است:
@section Scripts
{
    <script type="text/javascript">
    $(document).ready(function () {
        $('#list').jqGrid({
            caption: "آزمایش ششم",
                 // مانند قبل
            }).navGrid(
                 // مانند قبل
                    }).jqGrid('navButtonAdd', '#pager', {
                        caption: "", buttonicon: "ui-icon-print", title: "خروجی پی دی اف",
                        onClickButton: function () {
                            $("#list").jqGrid('excelExport', { url: '@Url.Action("GetProducts", "Home")' });
                        }
                    });
        });
    </script>
}

در اینجا توسط متد navButtonAdd یک دکمه‌ی جدید را اضافه کرده‌ایم که کلیک بر روی آن سبب فراخوانی متد excelExport و ارسال اطلاعات گزارش به url تنظیم شده‌است. باید دقت داشت که این اطلاعات از طریق Http Get به سرور ارسال می‌شوند و دقیقا اجزای آن همان اجزای جستجوی پویای jqGrid است:
public ActionResult GetProducts(string sidx, string sord, int page, int rows,
                                             bool _search, string searchField, string searchString,
                                             string searchOper, string filters, string oper)
با این تفاوت که یک oper نیز به مجموعه‌ی پارامترهای ارسالی به سرور اضافه شده‌است. این oper در اینجا با excel مقدار دهی می‌شود.
البته چون تعداد این پارامترها بیش از اندازه شده‌است، بهتر است آن‌ها را تبدیل به یک کلاس کرد:
namespace jqGrid06.Models
{
    public class JqGridRequest
    {
        public string sidx { set; get; }
        public string sord { set; get; }
        public int page { set; get; }
        public int rows { set; get; }
        public bool _search { set; get; }
        public string searchField { set; get; }
        public string searchString { set; get; }
        public string searchOper { set; get; }
        public string filters { set; get; }
        public string oper { set; get; }
    }
}
و متد جستجوی پویا را به نحو ذیل بازنویسی نمود:
        public ActionResult GetProducts(JqGridRequest request)
        {
            var list = ProductDataSource.LatestProducts;

            var pageIndex = request.page - 1;
            var pageSize = request.rows;
            var totalRecords = list.Count;
            var totalPages = (int)Math.Ceiling(totalRecords / (float)pageSize);

            var productsQuery = list.AsQueryable();

            productsQuery = new JqGridSearch().ApplyFilter(productsQuery, request, this.Request.Form);
            productsQuery = productsQuery.OrderBy(request.sidx + " " + request.sord);

            if (string.IsNullOrWhiteSpace(request.oper))
            {
                productsQuery = productsQuery
                                    .Skip(pageIndex * pageSize)
                                    .Take(pageSize);
            }
            else if (request.oper == "excel")
            {
                productsQuery = productsQuery
                                    .Skip(pageIndex * pageSize);
            }

            var productsList = productsQuery.ToList();

            if (!string.IsNullOrWhiteSpace(request.oper) && request.oper == "excel")
            {
                new ProductsPdfReport().CreatePdfReport(productsList);
            }

            var productsData = new JqGridData
            {
                Total = totalPages,
                Page = request.page,
                Records = totalRecords,
                Rows = (productsList.Select(product => new JqGridRowData
                {
                    Id = product.Id,
                    RowCells = new List<string>
                    {
                        product.Id.ToString(CultureInfo.InvariantCulture),
                        product.Name,
                        product.AddDate.ToPersianDate(),
                        product.Price.ToString(CultureInfo.InvariantCulture)
                    }
                })).ToArray()
            };

            return Json(productsData, JsonRequestBehavior.AllowGet);
        }

توضیحات:
اکثر قسمت‌های این متد با متدی که در مطلب «فعال سازی و پردازش جستجوی پویای jqGrid در ASP.NET MVC» مشاهده کردید یکی است؛ برای مثال order by آن با استفاده از کتابخانه‌ی Dynamic LINQ به صورت پویا عمل می‌کند و متد ApplyFilter، کار تهیه where پویا را انجام می‌دهد.
فقط در اینجا بررسی و پردازش پارامتر oper نیز اضافه شده‌است. اگر این پارامتر مقدار دهی شده باشد، یعنی نیاز است کل اطلاعات را واکشی کرد؛ زیرا می‌خواهیم گزارش گیری کنیم و نه اینکه صرفا اطلاعات یک صفحه را به کاربر بازگشت دهیم. همچنین در اینجا List نهایی فیلتر شده به یک گزارش Pdf Report ارسال می‌شود. این گزارش چون نهایتا اطلاعات را در مرورگر کاربر Flush می‌کند، کار به اجرای سایر قسمت‌ها نخواهد رسید و همینجا گزارش نهایی تهیه می‌شود.



کدهای کامل این مثال را از اینجا می‌توانید دریافت کنید
jqGrid06.7z
 
نظرات مطالب
قرار دادن نمودارهای MS Chart در گزارشات PdfReport
بله. از فونت‌های فارسی تغییر یافته استفاده کنید. برای مثال فونت irsans ایی که در پوشه bin مثال‌های پروژه هست، از این نوع است. فونت‌های معمولی رو در یک فونت ادیتور باز می‌کنند و بجای اعداد انگلیسی آن، معادل اعداد فارسی موجود در همان فایل فونت را کپی و پیست می‌کنند. این روش از قدیم برای ساخت گزارشات فارسی کاربرد داشته.
ضمنا اگر فونت مورد نظر بر روی سرور نصب نیست، به این صورت هم قابل بارگذاری است:
var privateFontCollection = new PrivateFontCollection();                            
privateFontCollection.AddFontFile(fontPath);
var fontFamily = privateFontCollection.Families[0];
var font = new Font(fontFamily);

مطالب
شروع به کار با EF Core 1.0 - قسمت 11 - بررسی رابطه‌ی Self Referencing
پیشنیازها
- بررسی نحوه تعریف نگاشت جداول خود ارجاع دهنده (Self Referencing Entity)
- مباحث تکمیلی مدل‌های خود ارجاع دهنده در EF Code first
- آشنایی با SQL Server Common Table Expressions - CTE
- بدست آوردن برگهای یک درخت توسط Recursive CTE


در پیشنیازهای بحث، روش تعریف روابط خود ارجاع دهنده و یا Self Referencing را تا EF 6.x می‌توانید مطالعه کنید. در این قسمت قصد داریم معادل این روش‌ها را در EF Core بررسی کنیم.


روش تعریف روابط خود ارجاع دهنده توسط Fluent API در EF Core

اگر همان مدل کامنت‌های چندسطحی یک بلاگ را درنظر بگیریم:
    public class BlogComment
    {
        public int Id { get; set; }

        public string Body { get; set; }

        public DateTime Date1 { get; set; }


        public virtual BlogComment Reply { get; set; }
        public int? ReplyId { get; set; }

        public virtual ICollection<BlogComment> Children { get; set; }
    }
اینبار تنظیمات Fluent API معادل EF Core آن به صورت ذیل خواهد بود:
    public class MyDBDataContext : DbContext
    {
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(@"Data Source=(local);Initial Catalog=testdb2;Integrated Security = true");
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<BlogComment>(entity =>
            {
                entity.HasIndex(e => e.ReplyId);

                entity.HasOne(d => d.Reply)
                    .WithMany(p => p.Children)
                    .HasForeignKey(d => d.ReplyId);
            });
        }

        public virtual DbSet<BlogComment> BlogComments { get; set; }
    }
هدف از مدل‌های خود ارجاع دهنده، مدلسازی اطلاعات چند سطحی است؛ مانند منوهای چندسطحی یک سایت، کامنت‌های چند سطحی یک مطلب، سلسله مراتب کارمندان یک شرکت و امثال آن.
نکته‌ها‌ی مهم مدلسازی این نوع روابط، موارد ذیل هستند:
الف) وجود خاصیتی از جنس کلاس اصلی در همان کلاس
   public virtual BlogComment Reply { get; set; }
ب) که در حقیقت مشخص می‌کند، والد رکورد جاری کدام رکورد قبلی است:
   public int? ReplyId { get; set; }
برای اینکه چنین رابطه‌ای تشکیل شود، باید این فیلد، مقدارش را از کلید اصلی جدول تامین کند (تشکیل یک کلید خارجی که به کلید اصلی جدول اشاره می‌کند).
علت نال پذیر بودن این خاصیت، نیاز به ثبت ریشه‌ای است که خودش والد است و فرزند رکوردی پیشین، نیست.


ج) همچنین برای سهولت دریافت فرزندان یک ریشه، مجموعه‌ای از جنس همان کلاس نیز تشکیل می‌شود.
 public virtual ICollection<BlogComment> Children { get; set; }


روش تعریف روابط خود ارجاع دهنده توسط Data Annotations در EF Core

در ادامه معادل تنظیمات فوق را توسط ویژگی‌ها ملاحظه می‌کنید که در اینجا توسط ویژگی‌های InverseProperty و ForeignKey، کار تشکیل روابط صورت گرفته‌است:
    public class BlogComment
    {
        public int Id { get; set; }
        public string Body { get; set; }
        public DateTime Date1 { get; set; }

        [ForeignKey("ReplyId")]
        [InverseProperty("Children")]
        public virtual BlogComment Reply { get; set; }
        public int? ReplyId { get; set; }

        [InverseProperty("Reply")]
        public virtual ICollection<BlogComment> Children { get; set; }
    }
البته قسمت تشکیل ایندکس بر روی ReplyId را فقط توسط Fluent API می‌توان انجام داد:
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<BlogComment>(entity =>
            {
                entity.HasIndex(e => e.ReplyId);
            });
        }


ثبت اطلاعات و کوئری گرفتن از روابط خود ارجاع دهنده در EF Core

در اینجا نحوه‌ی ثبت دو سری نظر و زیر نظر را مشاهده می‌کنید:
var comment1 = new BlogComment { Body = "نظر من این است که" };
var comment12 = new BlogComment { Body = "پاسخی به نظر اول", Reply = comment1 };
var comment121 = new BlogComment { Body = "پاسخی به پاسخ به نظر اول", Reply = comment12 };

context.BlogComments.Add(comment121);

var comment2 = new BlogComment { Body = "نظر من این بود که" };
var comment22 = new BlogComment { Body = "پاسخی به نظر قبلی", Reply = comment2 };
var comment221 = new BlogComment { Body = "پاسخی به پاسخ به نظر من اول", Reply = comment22 };
context.BlogComments.Add(comment221);

context.SaveChanges();


نظرات اصلی، با ReplyId مساوی نال قابل تشخیص هستند. سایر نظرات، توسط همین ReplyId به یکی از Idهای موجود، متصل شده‌اند.

در تصویر فوق و با توجه به اطلاعات ثبت شده، فرض کنید می‌خواهیم ریشه‌ی با id مساوی 1 و تمام زیر ریشه‌های آن‌را بیابیم. انجام یک چنین کاری نه در EF Core و نه در EF 6.x، پشتیبانی نمی‌شود. بدیهی است در اینجا هنوز روش‌های Include و ThenInclue هم جواب می‌دهند؛ اما چون Lazy loading فعال نیست، عملا نمی‌توان تمام زیر ریشه‌ها را یافت و همچنین به چندین و چند رفت و برگشت به ازای هر زیر ریشه خواهیم رسید که اصلا بهینه نیست.
برای اینکار نیاز است مستقیما کوئری نویسی کرد که در مطلب «شروع به کار با EF Core 1.0 - قسمت 10 - استفاده از امکانات بومی بانک‌های اطلاعاتی» زیر ساخت آن‌را بررسی کردیم:
var id = 1;
var childIds = new List<int>();
 
var connection = context.Database.GetDbConnection();
connection.Open();
var command = connection.CreateCommand();
command.CommandText =
    @"WITH Hierachy(ChildId, ParentId) AS (
                SELECT Id, ReplyId
                    FROM BlogComments
                UNION ALL
                SELECT h.ChildId, bc.ReplyId
                    FROM BlogComments bc
                    INNER JOIN Hierachy h ON bc.Id = h.ParentId
            )
 
            SELECT h.ChildId
                FROM Hierachy h
                WHERE h.ParentId = @Id";
command.Parameters.Add(new SqlParameter("id", id));
 
var reader = command.ExecuteReader();
while (reader.Read())
{
    var childId = (int)reader[0];
    childIds.Add(childId);
}
reader.Dispose();

 
var list = context.BlogComments
                .Where(blogComment => blogComment.Id == id ||
                                      childIds.Contains(blogComment.Id))
                .ToList();
زمانیکه کدهای فوق اجرا می‌شوند، تنها دو کوئری ذیل به بانک اطلاعاتی ارسال خواهند شد:
exec sp_executesql N'WITH Hierachy(ChildId, ParentId) AS (
                            SELECT Id, ReplyId
                                FROM BlogComments
                            UNION ALL
                            SELECT h.ChildId, bc.ReplyId
                                FROM BlogComments bc
                                INNER JOIN Hierachy h ON bc.Id = h.ParentId
                        )

                        SELECT h.ChildId
                            FROM Hierachy h
                            WHERE h.ParentId = @Id',N'@id int',@id=1
که لیست Idهای تمام زیر ریشه‌های مربوط به id مساوی یک را بر می‌گرداند:


و پس از آن:
 exec sp_executesql N'SELECT [blogComment].[Id], [blogComment].[Body], [blogComment].[Date1], [blogComment].[ReplyId]
FROM [BlogComments] AS [blogComment]
WHERE [blogComment].[Id] IN (@__id_0, 3, 5)',N'@__id_0 int',@__id_0=1
اکنون که Idهای مساوی 3 و 5 را یافتیم، با استفاده از متد Contains آن‌ها را تبدیل به where in کرده و لیست نهایی گزارش را تهیه می‌کنیم:


یافتن Idهای زیر ریشه‌ها توسط روش CTE (پیشنیازهای ابتدای بحث) و سپس کوئری گرفتن بر روی این Idها، بهینه‌ترین روشی‌است که در EF می‌توان جهت کار با مدل‌های خود ارجاع دهنده بکار گرفت.
نظرات مطالب
ارتقاء به ASP.NET Core 1.0 - قسمت 8 - فعال سازی ASP.NET MVC
این باگ را در اینجا گزارش کنید (به نظر structuremap.dnx هنوز برای نگارش RTM آماده نیست).

به روز رسانی
این مساله در اینجا گزارش شده و عنوان کرده‌اند که یک populate اضافی دارد:
private IServiceProvider IocConfig(IServiceCollection services)
        {
            var container = new Container();
            container.Configure(config =>
            {
                //config.Populate(services); ---> این اضافی است
            });

            container.Populate(services);
            return container.GetInstance<IServiceProvider>();
        }
بازخوردهای پروژه‌ها
راهنمایی در مورد ایجاد columnها
با سلام خدمت آقای نصیری
من برای ایجاد column‌های گزارش خودم از کد زیر استفاده کردم

 .MainTableDataSource(dataSource =>
            {
                var ctx = new clearanceEntities();
                var list = (from c in ctx.CLEARANCE_COST
                            join r in ctx.CLEARANCE_REQUEST on
                                c.REQUEST_ID equals r.REQUEST_ID
                            where c.REQUEST_ID == 3
                            select new
                            {
                                CostName = c.COST_TYPES.COST_NAME,
                                CostAmount = c.COST_AMOUNT,
                               

                            }).ToList();

                dataSource.StronglyTypedList(list);
            })
            
            .MainTableColumns(columns =>
            {
                columns.AddColumn(column =>
                {
                    column.PropertyName("rowNo");
                    column.IsRowNumber(true);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(0);
                    column.Width(1);
                    column.HeaderCell("#");
                });

                

                columns.AddColumn(column =>
                {
                    column.PropertyName<CLEARANCE_COST>(x =>x.COST_TYPES.COST_NAME);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(1);
                    column.Width(3);
                    column.HeaderCell("توضیحات");
                });

                columns.AddColumn(column =>
                {
                    column.PropertyName<CLEARANCE_COST>(x=>x.REMARK);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(2);
                    column.Width(3);
                    column.HeaderCell("شماره فیش بانکی/سند");
                });

                columns.AddColumn(column =>
                {
                    column.PropertyName<CLEARANCE_COST>(x => x.COST_AMOUNT);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(3);
                    column.Width(2);
                    column.HeaderCell("مبلغ");
                   
                });

            })
            .MainTableEvents(events =>
            {
                events.DataSourceIsEmpty(message: "There is no data available to display.");
            })
ولی وقتی که اجرا می‌کردم اول پیغام There is no data available to display رو نشون می‌داد ولی حال پیغام می‌دهد که Column ی به عنوان COST_TYPES.COST_NAME وجود ندارد. من حدس می‌زنم در تعریف خودم یه اشکالی وجود دارد ممنون میشم راهنمایی کنید.
نظرات مطالب
اجرای وظایف زمان بندی شده با Quartz.NET - قسمت اول
از DNT Scheduler استفاده کنید. در اینجا اگر یک job هنوز در حال اجرا باشد، وهله‌ی جدیدی از آن آغاز نخواهد شد:
            var taskToRun = _tasks.Where(x => !x.IsRunning && x.RunAt(now)).OrderBy(x => x.Order).ToList();
در قسمت IsRunning این بررسی به صورت خودکار انجام می‌شود.
مطالب
حذف جوین‌های اضافی به یک جدول در Entity framework
تذکر: این مطلب و نکته برای تا EF 6.1.3 تهیه شده‌است و ممکن است در نگارش‌های آتی آن وجود نداشته یا برطرف شده‌باشد.

کوئری ذیل را در نظر بگیرید:
var productsList1 = ctx.Products.Where(product => product.Id > 1)
    .Include(product => product.Category)
    .Include(product => product.User)
    .Where(
        product =>
            product.Category.Title.Contains("t") && product.Category.Id > 1 && product.Price > 100)
    .OrderBy(product => product.Price)
    .ToList();
به نظر شما این کوئری چند Join را ایجاد می‌کند؟
احتمالا شاید عنوان کنید که به ازای هر Include یک join خواهیم داشت. بنابراین دو جوین به جداول کاربران و گروه‌های محصول‌ها ایجاد می‌شود.

اما ... در واقعیت این کوئری را تولید می‌کند:
SELECT
    [Extent1].[Id] AS [Id],
    [Extent1].[Name] AS [Name],
    [Extent1].[Price] AS [Price],
    [Extent1].[CategoryId] AS [CategoryId],
    [Extent1].[UserId] AS [UserId],
    [Extent3].[Id] AS [Id1],
    [Extent3].[Name] AS [Name1],
    [Extent3].[Title] AS [Title],
    [Extent3].[UserId] AS [UserId1],
    [Extent4].[Id] AS [Id2],
    [Extent4].[Name] AS [Name2]
    FROM    [dbo].[Products] AS [Extent1]
    INNER JOIN [dbo].[Categories] AS [Extent2] ON [Extent1].[CategoryId] = [Extent2].[Id]
    LEFT OUTER JOIN [dbo].[Categories] AS [Extent3] ON [Extent1].[CategoryId] = [Extent3].[Id]
    LEFT OUTER JOIN [dbo].[Users] AS [Extent4] ON [Extent1].[UserId] = [Extent4].[Id]
    WHERE ([Extent1].[Id] > 1) AND ([Extent2].[Title] LIKE N'%t%') AND ([Extent1].[CategoryId] > 1) AND ([Extent1].[Price] > 100)
    ORDER BY [Extent1].[Price] ASC
اگر به قسمت جوین‌های آن دقت کنید دوبار جوین به جدول Categories را می‌توانید مشاهده کنید.
این دو جوین حاصل یکبار Include جدول Categories و یکبار استفاده از navigation property آن در قسمت where است.

این باگ در اینجا گزارش شده، ولی به نظر هنوز برطرف نشده‌است یا مجددا ظاهر شده‌است.

برای رفع آن در حال حاضر بهترین راه حل استفاده از روش ذیل است:
var query2 = from product in ctx.Products
             let category = product.Category
             where product.Id > 1
             where category.Title.Contains("t") && category.Id > 1 && product.Price > 100
             select new { product, category }; 
var productsList2 = query2.ToList();
در اینجا قسمت Include کلا حذف شده و همچنین گروه‌ها توسط یک متغیر موقتی که با let ایجاد شده‌است، استفاده می‌شود. خروجی آن کوئری ذیل است:
SELECT
    [Extent1].[Id] AS [Id],
    [Extent1].[Name] AS [Name],
    [Extent1].[Price] AS [Price],
    [Extent1].[CategoryId] AS [CategoryId],
    [Extent1].[UserId] AS [UserId],
    [Extent2].[Id] AS [Id1],
    [Extent2].[Name] AS [Name1],
    [Extent2].[Title] AS [Title],
    [Extent2].[UserId] AS [UserId1]
    FROM  [dbo].[Products] AS [Extent1]
    INNER JOIN [dbo].[Categories] AS [Extent2] ON [Extent1].[CategoryId] = [Extent2].[Id]
    WHERE ([Extent1].[Id] > 1) AND ([Extent2].[Title] LIKE N'%t%') AND ([Extent2].[Id] > 1) AND ([Extent1].[Price] > 100)
همانطور که مشاهده می‌کنید، اینبار فقط یکبار جوین به جدول گروه‌ها تشکیل شده‌است.

چند نکته:
-در کوئری let دار، اگر در قسمت select نهایی فقط product ذکر شود، هرچند جوین به جدول گروه‌ها تشکیل می‌شود اما فیلدهای این جدول انتخاب نخواهند شد.
-معادل کوئری LINQ نوشته شده را اگر بخواهیم توسط متدهای الحاقی بازنویسی کنیم، به کوئری ذیل خواهیم رسید:
var query2ChainedVersion = ctx.Products
     .Select(product => new { product, category = product.Category })
     .Where(@t => @t.product.Id > 1)
     .Where(@t => @t.category.Title.Contains("t") && @t.category.Id > 1 && @t.product.Price > 100)
     .Select(@t => new { @t.product , @t.category });

اگر علاقمند به آزمایش این باگ هستید، کدهای کامل آن‌را از اینجا می‌توانید دریافت کنید:
  Sample38.zip
بازخوردهای پروژه‌ها
کندی ایجاد فایل pdf
با سلام . تهیه گزارش زیر نزدیک به 19 ثانیه طول می‌کشد. علت در چیست؟
  public IPdfReportData Create()
        {
            return new PdfReport().DocumentPreferences(doc =>
            {
                //DocumentMargins margin = new DocumentMargins()
                //{
                //    Bottom = 5,
                //    Left = 5,
                //    Right = 5,
                //    Top = 5,
                //};
                //doc.DocumentMargins(margin);
                //PrintingPreferences printPreference = new PrintingPreferences()
                //{
                //    ShowPrintDialogAutomatically = true,
                //};
                //doc.PrintingPreferences(printPreference);
                doc.RunDirection(PdfRunDirection.RightToLeft);
                doc.Orientation(PageOrientation.Landscape);
                doc.PageSize(PdfPageSize.A4);
                doc.DocumentMetadata(new DocumentMetadata
                {
                    Author = "ILIA",
                    Application = "PdfRpt",
                    Keywords = "IList Rpt.",
                    Subject = "ابلاغ استاد",
                    Title = "ابلاغ استاد"
                });
            })
            .DefaultFonts(fonts =>
            {
                fonts.Size(8);
                fonts.Path(Environment.GetEnvironmentVariable("SystemRoot") + "\\fonts\\BYekan.ttf",
                           Environment.GetEnvironmentVariable("SystemRoot") + "\\fonts\\tahoma.ttf");
            })
            .PagesFooter(footer =>
            {
                footer.DefaultFooter(DateTime.Now.ToString("MM/dd/yyyy"));
            })
            .PagesHeader(header =>
            {
                //header.PdfFont.Fonts.Add(new iTextSharp.text.Font("BYekan", 10f,0, new BaseColor(Color.Black)));
                //header.PdfFont.Fonts.Add(new iTextSharp.text.Font("BYekan", 10f, new BaseColor(Color.Black)));
                //CH_Rpt_TeacherEblagh ch = new CH_Rpt_TeacherEblagh(TermId.ToString(), "ساری", CenterId.ToString());
                //header.CustomHeader(ch);
                header.DefaultHeader(defaultHeader =>
                {
                    defaultHeader.RunDirection(PdfRunDirection.RightToLeft);
                    //defaultHeader.ImagePath(AppPath.ApplicationPath + "\\Images\\01.png");
                    defaultHeader.Message("ابلاغ استاد");
                });
            })
            .MainTableTemplate(template =>
            {
                template.BasicTemplate(BasicTemplate.SilverTemplate);
            })
            .MainTablePreferences(table =>
            {
                table.ColumnsWidthsType(TableColumnWidthType.Absolute);
                table.NumberOfDataRowsPerPage(0);
            })
            .MainTableDataSource(dataSource =>
            {
                dataSource.StronglyTypedList<ProgramRowSemiInfo>(resultSource);
            })
            .MainTableSummarySettings(summarySettings =>
            {
                //summarySettings.OverallSummarySettings("Summary");
                // summarySettings.PreviousPageSummarySettings("Previous Page Summary");
                // summarySettings.PageSummarySettings("Page Summary");
            })
            .MainTableColumns(columns =>
            {
                columns.AddColumn(column =>
                {
                    column.PropertyName("rowNo");
                    column.IsRowNumber(true);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(0);
                    column.Width(15);
                    column.HeaderCell("#", horizontalAlignment: HorizontalAlignment.Center);
                });
                columns.AddColumn(column =>
                {
                    column.PropertyName<ProgramRowSemiInfo>(x => x.LessonId);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Left);
                    column.IsVisible(true);
                    column.Order(1);
                    column.Width(35);
                    column.HeaderCell("کد درس", horizontalAlignment: HorizontalAlignment.Left);
                });
                columns.AddColumn(column =>
                {
                    column.PropertyName<ProgramRowSemiInfo>(x => x.LessonName);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Left);
                    column.IsVisible(true);
                    column.Order(2);
                    column.Width(80);
                    column.HeaderCell("عنوان درس", horizontalAlignment: HorizontalAlignment.Left);
                });
                columns.AddColumn(column =>
                {
                    column.PropertyName<ProgramRowSemiInfo>(x => x.GroupNumber);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(2);
                    column.Width(20);
                    column.HeaderCell("گروه", horizontalAlignment: HorizontalAlignment.Center);
                });
                //columns.AddColumn(column =>
                //{
                //    column.PropertyName<ProgramRowSemiInfo>(x => x.TrendName);
                //    column.CellsHorizontalAlignment(HorizontalAlignment.Left);
                //    column.IsVisible(true);
                //    column.Order(3);
                //    column.Width(100);
                //    column.HeaderCell("عنوان رشته", horizontalAlignment: HorizontalAlignment.Left);
                //});
                columns.AddColumn(column =>
                {
                    column.PropertyName<ProgramRowSemiInfo>(x => x.TimeShort);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(3);
                    column.Width(30);
                    column.HeaderCell("ساعت", horizontalAlignment: HorizontalAlignment.Center);
                });
                columns.AddColumn(column =>
                {
                    column.PropertyName<ProgramRowSemiInfo>(x => x.DaySemiTitle);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(3);
                    column.Width(15);
                    column.HeaderCell("روز", horizontalAlignment: HorizontalAlignment.Center);
                });
                columns.AddColumn(column =>
                {
                    column.PropertyName<ProgramRowSemiInfo>(x => x.SessionCount);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Left);
                    column.IsVisible(true);
                    column.Order(3);
                    column.Width(15);
                    column.HeaderCell("ت ج", horizontalAlignment: HorizontalAlignment.Left);
                });
                columns.AddColumn(column =>
                {
                    column.PropertyName<ProgramRowSemiInfo>(x => x.SumOfTeachStringShort);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(3);
                    column.Width(25);
                    column.HeaderCell("جمع ساعت", horizontalAlignment: HorizontalAlignment.Center);
                });
                columns.AddColumn(column =>
                {
                    column.PropertyName<ProgramRowSemiInfo>(x => x.PlaceShortName);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Left);
                    column.IsVisible(true);
                    column.Order(3);
                    column.Width(42);
                    column.HeaderCell("ساختمان", horizontalAlignment: HorizontalAlignment.Left);
                });
                columns.AddColumn(column =>
                {
                    column.PropertyName<ProgramRowSemiInfo>(x => x.ClassShortName);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Left);
                    column.IsVisible(true);
                    column.Order(3);
                    column.Width(50);
                    column.HeaderCell("کلاس", horizontalAlignment: HorizontalAlignment.Left);
                });
                int maxSessionCount = resultSource.Max(p => p.SessionCount);
                for (int i = 0; i < maxSessionCount; i++)
                {
                    columns.AddColumn(column =>
                    {
                        column.PropertyName("S" + (i + 1));
                        column.CellsHorizontalAlignment(HorizontalAlignment.Left);
                        column.IsVisible(true);
                        column.Order(4);
                        column.Width(25);
                        column.HeaderCell("ج " + (i + 1), horizontalAlignment: HorizontalAlignment.Center);
                        //column.ColumnItemsTemplate(t => t.CustomTemplate(new SessionCustomCellTemplate()));
                    });
                }
            })
            .MainTableEvents(events =>
            {
                events.DataSourceIsEmpty(message: "داده ای برای مشاهده وجود ندارد.");
            })
            .Export(export =>
            {
                //export.ToExcel();
                //export.ToCsv();
                //export.ToXml();
            })
            .Generate(data => data.AsPdfFile(string.Format("{0}\\{1}", ReportPathHelper.ReportsPath, ReportFileName)));
        }
اینهم سرویسی که این گزارش را ایجاد میکند:
[ServiceContract(Namespace = "")]
    [SilverlightFaultBehavior]
    [AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Allowed)]
    public class PdfReportService
    {
        [OperationContract]
        public string CreateReport(int centerId, int termId, int teacherId)
        {
            Stopwatch sw = Stopwatch.StartNew();
            var rpt = new Rpt_TeacherEblagh(centerId, termId, teacherId);
            IPdfReportData rptData = rpt.Create();
            sw.Stop();
            long m = sw.ElapsedMilliseconds;
            string result = rptData.FileName.Replace(HttpRuntime.AppDomainAppPath, string.Empty);
            return result;
        }
    }
این هم خروجی گزارش که تنها یک صفحه دارد ولی باز حدود 20 ثانیه طول می‌کشد:

مطالب
آشنایی با NHibernate - قسمت ششم

آشنایی با Automapping در فریم ورک Fluent NHibernate

اگر قسمت‌های قبل را دنبال کرده باشید، احتمالا به پروسه طولانی ساخت نگاشت‌ها توجه کرده‌اید. با کمک فریم ورک Fluent NHibernate می‌توان پروسه نگاشت domain model خود را به data model متناظر آن به صورت خودکار نیز انجام داد و قسمت عمده‌ای از کار به این صورت حذف خواهد شد. (این مورد یکی از تفاوت‌های مهم NHibernate با نمونه‌های مشابهی است که مایکروسافت تا تاریخ نگارش این مقاله ارائه داده است. برای مثال در نگار‌ش‌های فعلی LINQ to SQL یا Entity framework ، اول دیتابیس مطرح است و بعد ساخت کد از روی آن، در حالیکه در اینجا ابتدا کد و طراحی سیستم مطرح است و بعد نگاشت آن به سیستم داده‌ای و دیتابیس)

امروز قصد داریم یک سیستم ساده ثبت خبر را از صفر با NHibernate پیاده سازی کنیم و همچنین مروری داشته باشیم بر قسمت‌های قبلی.

مطابق کلاس دیاگرام فوق، این سیستم از سه کلاس خبر، کاربر ثبت کننده‌ی خبر و گروه خبری مربوطه تشکیل شده است.

ابتدا یک پروژه کنسول جدید را به نام NHSample2 آغاز کنید. سپس ارجاعاتی را به اسمبلی‌های زیر به آن اضافه نمائید:
FluentNHibernate.dll
NHibernate.dll
NHibernate.ByteCode.Castle.dll
NHibernate.Linq.dll
و ارجاعی به اسمبلی استاندارد System.Data.Services.dll دات نت فریم ورک سه و نیم

سپس پوشه‌ای را به نام Domain به این پروژه اضافه نمائید (کلیک راست روی نام پروژه در VS.Net و سپس مراجعه به منوی Add->New folder). در این پوشه تعاریف موجودیت‌های برنامه را قرار خواهیم داد. سه کلاس جدید Category ، User و News را در این پوشه ایجاد نمائید. محتویات این سه کلاس به شرح زیر هستند:

namespace NHSample2.Domain
{
public class User
{
public virtual int Id { get; set; }
public virtual string UserName { get; set; }
public virtual string Password { get; set; }
}
}


namespace NHSample2.Domain
{
public class Category
{
public virtual int Id { get; set; }
public virtual string CategoryName { get; set; }
}
}


using System;

namespace NHSample2.Domain
{
public class News
{
public virtual Guid Id { get; set; }
public virtual string Subject { get; set; }
public virtual string NewsText { get; set; }
public virtual DateTime DateEntered { get; set; }
public virtual Category Category { get; set; }
public virtual User User { get; set; }
}
}
همانطور که در قسمت‌های قبل نیز ذکر شد، تمام خواص پابلیک کلاس‌های Domain ما به صورت virtual تعریف شده‌اند تا lazy loading را در NHibernate فعال سازیم. در حالت lazy loading ، اطلاعات تنها زمانیکه به آن‌ها نیاز باشد بارگذاری خواهند شد. این مورد در حالتیکه نیاز به نمایش اطلاعات تنها یک شیء وجود داشته باشد بسیار مطلوب می‌باشد، یا هنگام ثبت و به روز رسانی اطلاعات نیز یکی از بهترین روش‌ها است. اما زمانیکه با لیستی از اطلاعات سروکار داشته باشیم باعث کاهش افت کارآیی خواهد شد زیرا برای مثال نمایش آن‌ها سبب خواهد شد که 100 ها کوئری دیگر جهت دریافت اطلاعات هر رکورد در حال نمایش اجرا شود (مفهوم دسترسی به اطلاعات تنها در صورت نیاز به آن‌ها). Lazy loading و eager loading (همانند مثال‌های قبلی) هر دو در NHibernate به سادگی قابل تنظیم هستند (برای مثال LINQ to SQL به صورت پیش فرض همواره lazy load است و تا این تاریخ راه استانداردی برای امکان تغییر و تنظیم این مورد پیش بینی نشده است).

اکنون کلاس جدید Config را به برنامه اضافه نمائید:

using FluentNHibernate.Automapping;
using FluentNHibernate.Cfg;
using FluentNHibernate.Cfg.Db;
using NHibernate;
using NHibernate.Cfg;
using NHibernate.Tool.hbm2ddl;

namespace NHSample2
{
class Config
{
public static Configuration GenerateMapping(IPersistenceConfigurer dbType)
{
var cfg = dbType.ConfigureProperties(new Configuration());

new AutoPersistenceModel()
.Where(x => x.Namespace.EndsWith("Domain"))
.AddEntityAssembly(typeof(NHSample2.Domain.News).Assembly).Configure(cfg);

return cfg;
}

public static void GenerateDbScript(Configuration config, string filePath)
{
bool script = true;//فقط اسکریپت دیتابیس تولید گردد
bool export = false;//نیازی نیست بر روی دیتابیس هم اجرا شود
new SchemaExport(config).SetOutputFile(filePath).Create(script, export);
}

public static void BuildDbSchema(Configuration config)
{
bool script = false;//آیا خروجی در کنسول هم نمایش داده شود
bool export = true;//آیا بر روی دیتابیس هم اجرا شود
bool drop = false;//آیا اطلاعات موجود دراپ شوند
new SchemaExport(config).Execute(script, export, drop);
}

public static void CreateSQL2008DbPlusScript(string connectionString, string filePath)
{
Configuration cfg =
GenerateMapping(
MsSqlConfiguration
.MsSql2008
.ConnectionString(connectionString)
.ShowSql()
);
GenerateDbScript(cfg, filePath);
BuildDbSchema(cfg);
}

public static ISessionFactory CreateSessionFactory(IPersistenceConfigurer dbType)
{
return
Fluently.Configure().Database(dbType)
.Mappings(m => m.AutoMappings
.Add(
new AutoPersistenceModel()
.Where(x => x.Namespace.EndsWith("Domain"))
.AddEntityAssembly(typeof(NHSample2.Domain.News).Assembly))
)
.BuildSessionFactory();
}
}
}

در متد GenerateMapping از قابلیت Automapping موجود در فریم ورک Fluent Nhibernate استفاده شده است (بدون نوشتن حتی یک سطر جهت تعریف این نگاشت‌ها). این متد نوع دیتابیس مورد نظر را جهت ساخت تنظیمات خود دریافت می‌کند. سپس با کمک کلاس AutoPersistenceModel این فریم ورک، به صورت خودکار از اسمبلی برنامه نگاشت‌های لازم را به کلاس‌های موجود در پوشه Domain ما اضافه می‌کند (مرسوم است که این پوشه در یک پروژه Class library مجزا تعریف شود که در این برنامه جهت سهولت کار در خود برنامه قرار گرفته است). قسمت Where ذکر شده به این جهت معرفی گردیده است تا Fluent Nhibernate برای تمامی کلاس‌های موجود در اسمبلی جاری، سعی در تعریف نگاشت‌های لازم نکند. این نگاشت‌ها تنها به کلاس‌های موجود در پوشه دومین ما محدود شده‌اند.
سه متد بعدی آن، جهت ایجاد اسکریپت دیتابیس از روی این نگاشت‌های تعریف شده و سپس اجرای این اسکریپت بر روی دیتابیس جاری معرفی شده، تهیه شده‌اند. برای مثال CreateSQL2008DbPlusScript یک مثال ساده از استفاده دو متد قبلی جهت ایجاد اسکریپت و دیتابیس متناظر اس کیوال سرور 2008 بر اساس نگاشت‌های برنامه است.
با متد CreateSessionFactory در قسمت‌های قبل آشنا شده‌اید. تنها تفاوت آن در این قسمت، استفاده از کلاس AutoPersistenceModel جهت تولید خودکار نگاشت‌ها است.

در ادامه دیتابیس متناظر با موجودیت‌های برنامه را ایجاد خواهیم کرد:

using System;

namespace NHSample2
{
class Program
{
static void Main(string[] args)
{
Config.CreateSQL2008DbPlusScript(
"Data Source=(local);Initial Catalog=HelloNHibernate;Integrated Security = true",
"db.sql");

Console.WriteLine("Press a key...");
Console.ReadKey();
}
}
}

پس از اجرای برنامه، ابتدا فایل اسکریپت دیتابیس به نام db.sql در پوشه اجرایی برنامه تشکیل خواهد شد و سپس این اسکریپت به صورت خودکار بر روی دیتابیس معرفی شده اجرا می‌گردد. دیتابیس دیاگرام حاصل را در شکل زیر می‌توانید ملاحظه نمائید:



همچنین اسکریپت تولید شده آن، صرفنظر از عبارات drop اولیه، به صورت زیر است:

create table [Category] (
Id INT IDENTITY NOT NULL,
CategoryName NVARCHAR(255) null,
primary key (Id)
)

create table [User] (
Id INT IDENTITY NOT NULL,
UserName NVARCHAR(255) null,
Password NVARCHAR(255) null,
primary key (Id)
)

create table [News] (
Id UNIQUEIDENTIFIER not null,
Subject NVARCHAR(255) null,
NewsText NVARCHAR(255) null,
DateEntered DATETIME null,
Category_id INT null,
User_id INT null,
primary key (Id)
)

alter table [News]
add constraint FKE660F9E1C9CF79
foreign key (Category_id)
references [Category]

alter table [News]
add constraint FKE660F95C1A3C92
foreign key (User_id)

references [User]

اکنون یک سری گروه خبری، کاربر و خبر را به دیتابیس خواهیم افزود:

using System;
using FluentNHibernate.Cfg.Db;
using NHibernate;
using NHSample2.Domain;

namespace NHSample2
{
class Program
{
static void Main(string[] args)
{
using (ISessionFactory sessionFactory = Config.CreateSessionFactory(
MsSqlConfiguration
.MsSql2008
.ConnectionString("Data Source=(local);Initial Catalog=HelloNHibernate;Integrated Security = true")
.ShowSql()
))
{
using (ISession session = sessionFactory.OpenSession())
{
using (ITransaction transaction = session.BeginTransaction())
{
//با توجه به کلیدهای خارجی تعریف شده ابتدا باید گروه‌ها را اضافه کرد
Category ca = new Category() { CategoryName = "Sport" };
session.Save(ca);
Category ca2 = new Category() { CategoryName = "IT" };
session.Save(ca2);
Category ca3 = new Category() { CategoryName = "Business" };
session.Save(ca3);

//سپس یک کاربر را به دیتابیس اضافه می‌کنیم
User u = new User() { Password = "123$5@1", UserName = "VahidNasiri" };
session.Save(u);

//اکنون می‌توان یک خبر جدید را ثبت کرد

News news = new News()
{
Category = ca,
User = u,
DateEntered = DateTime.Now,
Id = Guid.NewGuid(),
NewsText = "متن خبر جدید",
Subject = "عنوانی دلخواه"
};
session.Save(news);

transaction.Commit(); //پایان تراکنش
}
}
}

Console.WriteLine("Press a key...");
Console.ReadKey();
}
}
}
جهت بررسی انجام عملیات ثبت هم می‌توان به دیتابیس مراجعه کرد، برای مثال:



و یا می‌توان از LINQ استفاده کرد:
برای مثال کاربر VahidNasiri تعریف شده را یافته، اطلاعات آن‌را نمایش دهید؛ سپس نام او را به Vahid ویرایش کرده و دیتابیس را به روز کنید.

برای اینکه کوئری‌های LINQ ما شبیه به LINQ to SQL شوند، کلاس NewsContext را به صورت ذیل تشکیل می‌دهیم. این کلاس از کلاس پایه NHibernateContext مشتق شده و سپس به ازای تمام موجودیت‌های برنامه، یک متد از نوع IOrderedQueryable را تشکیل خواهیم داد.

using System.Linq;
using NHibernate;
using NHibernate.Linq;
using NHSample2.Domain;

namespace NHSample2
{
class NewsContext : NHibernateContext
{
public NewsContext(ISession session)
: base(session)
{ }

public IOrderedQueryable<News> News
{
get { return Session.Linq<News>(); }
}

public IOrderedQueryable<Category> Categories
{
get { return Session.Linq<Category>(); }
}

public IOrderedQueryable<User> Users
{
get { return Session.Linq<User>(); }
}
}
}
اکنون جهت یافتن کاربر و به روز رسانی اطلاعات او در دیتابیس خواهیم داشت:

using System;
using FluentNHibernate.Cfg.Db;
using NHibernate;
using System.Linq;
using NHSample2.Domain;

namespace NHSample2
{
class Program
{
static void Main(string[] args)
{
using (ISessionFactory sessionFactory = Config.CreateSessionFactory(
MsSqlConfiguration
.MsSql2008
.ConnectionString("Data Source=(local);Initial Catalog=HelloNHibernate;Integrated Security = true")
.ShowSql()
))
{
using (ISession session = sessionFactory.OpenSession())
{
using (ITransaction transaction = session.BeginTransaction())
{
using (NewsContext db = new NewsContext(session))
{
var query = from x in db.Users
where x.UserName == "VahidNasiri"
select x;

//اگر چیزی یافت شد
if (query.Any())
{
User vahid = query.First();
//نمایش اطلاعات کاربر
Console.WriteLine("Id: {0}, UserName: {0}", vahid.Id, vahid.UserName);
//به روز رسانی نام کاربر
vahid.UserName = "Vahid";
session.Update(vahid);

transaction.Commit(); //پایان تراکنش
}
}
}
}
}

Console.WriteLine("Press a key...");
Console.ReadKey();
}
}
}
مباحث تکمیلی AutoMapping

اگر به اسکریپت دیتابیس تولید شده دقت کرده باشید، عملیات AutoMapping یک سری پیش فرض‌هایی را اعمال کرده است. برای مثال فیلد Id را از نوع identity و به صورت کلید تعریف کرده، یا رشته‌ها را به صورت nvarchar با طول 255 ایجاد نموده است. امکان سفارشی سازی این موارد نیز وجود دارد.

مثال:

using FluentNHibernate.Conventions.Helpers;

public static Configuration GenerateMapping(IPersistenceConfigurer dbType)
{
var cfg = dbType.ConfigureProperties(new Configuration());

new AutoPersistenceModel()
.Conventions.Add()
.Where(x => x.Namespace.EndsWith("Domain"))
.Conventions.Add(
PrimaryKey.Name.Is(x => "ID"),
DefaultLazy.Always(),
ForeignKey.EndsWith("ID"),
Table.Is(t => "tbl" + t.EntityType.Name)
)
.AddEntityAssembly(typeof(NHSample2.Domain.News).Assembly)
.Configure(cfg);

return cfg;
}

تابع GenerateMapping معرفی شده را اینجا با قسمت Conventions.Add تکمیل کرده‌ایم. به این صورت دقیقا مشخص شده است که فیلدهایی با نام ID باید primary key در نظر گرفته شوند، همواره lazy loading صورت گیرد و نام کلید خارجی به ID ختم شود. همچنین نام جداول با tbl شروع گردد.
روش دیگری نیز برای معرفی این قرار دادها و پیش فرض‌ها وجود دارد. فرض کنید می‌خواهیم طول رشته پیش فرض را از 255 به 500 تغییر دهیم. برای اینکار باید اینترفیس IPropertyConvention را پیاده سازی کرد:

using FluentNHibernate.Conventions;
using FluentNHibernate.Conventions.Instances;

namespace NHSample2.Conventions
{
class MyStringLengthConvention : IPropertyConvention
{
public void Apply(IPropertyInstance instance)
{
instance.Length(500);
}
}
}
سپس نحوه‌ی معرفی آن به صورت زیر خواهد بود:

public static Configuration GenerateMapping(IPersistenceConfigurer dbType)
{
var cfg = dbType.ConfigureProperties(new Configuration());

new AutoPersistenceModel()
.Conventions.Add()
.Where(x => x.Namespace.EndsWith("Domain"))
.Conventions.Add<MyStringLengthConvention>()
.AddEntityAssembly(typeof(NHSample2.Domain.News).Assembly)
.Configure(cfg);

return cfg;
}

نکته:
اگر برای یافتن اطلاعات بیشتر در این مورد در وب جستجو کنید، اکثر مثال‌هایی را که مشاهده خواهید کرد بر اساس نگارش بتای fluent NHibernate هستند و هیچکدام با نگارش نهایی این فریم ورک کار نمی‌کنند. در نگارش رسمی نهایی ارائه شده، تغییرات بسیاری صورت گرفته که آن‌ها را در این آدرس می‌توان مشاهده کرد.

دریافت سورس برنامه قسمت ششم


ادامه دارد ...