Historia faktur

Dzisiaj przedstawię niektóre elementy strony historii faktur. Słowo „niektóre” pojawia się dlatego, że strona ta czerpie garściami z innych – zaimplementowanych już i opisanych. Od razu pokażę jej wygląd, aby Czytelnik, czytając o kolejnych elementach, znał już ich umiejscowienie i przeznaczenie:

Filtrowanie
Filtrowanie danych omówiłem dość szczegółowo w tym wpisie. Wyjaśnię więc tylko, że faktury można filtrować po statusie, kliencie, produktach, dacie wystawienia i wartości. Na wyjaśnienie zasługuje filtrowanie produktów. Polega ono na tym, że użytkownik podaje początki nazw produktów, które powinna zawierać faktura, oddzielając je przecinkami. Oprócz podanych przez użytkownika, faktura może zawierać też inne produkty (czego nie widać na pokazanym screenie).

Status faktury
Możliwe statusy faktury to wystawiona i zapłacona. Pierwszy jest nadawany automatycznie po utworzeniu, drugi ustawia użytkownik (za pomocą przycisku zapłacona widocznego przy fakturze). Przy implementowaniu tej funkcjonalności, napotkałem problem natury językowej. Chciałem, aby w kodzie programu i bazie danych statusy miały nazwy angielskie (created, paid). Użytkownik powinien jednak widzieć je po polsku, dlatego napisałem dwie proste metody:

private static string TranslateInvoiceStatus(string status)
{
	if (status == Invoice.InvoiceStatus.Created.ToString())
		return "Wystawiona";
	else if (status == Invoice.InvoiceStatus.Paid.ToString())
		return "Zapłacona";
	else
		return "";
}

public static List<KeyValuePair<string, string>> GetInvoiceStatusList(this HtmlHelper helper) // ta metoda jest rozszerzeniem klasy HtmlHelper
{
	List<KeyValuePair<string, string>> result = new List<KeyValuePair<string, string>>();
	
	result.Add(new KeyValuePair<string, string>(TranslateInvoiceStatus(Invoice.InvoiceStatus.Created.ToString()), Invoice.InvoiceStatus.Created.ToString()));
	result.Add(new KeyValuePair<string, string>(TranslateInvoiceStatus(Invoice.InvoiceStatus.Paid.ToString()), Invoice.InvoiceStatus.Paid.ToString()));

	return result;
}

Przeznaczenie pierwszej metody jest uniwersalne, drugiej natomiast używam do wypełnienia rozwijanej listy statusów w filtrze faktur.

Termin płatności
Tutaj jedynie krótkie wyjaśnienie: jeśli faktura ma status inny niż zapłacona i jej termin płatności minął, jest on wyświetlany na czerwono – co widać na screenie.

PDF
Czas na najważniejszą funkcję całej aplikacji – generowanie pliku PDF. Po naciśnięciu przycisku PDF, widocznego przy każdej fakturze, użytownik wybiera, czy chce wygenerować oryginał dokumentu, czy jego kopię:

Po dokonaniu wyboru, wywoływana jest następująca metoda:

public ActionResult ToPdf(int id, PdfCreator.InvoiceType invoiceType)
{
	Invoice invoice = _invoiceRepository.GetById(id); // _invoiceRepository - prywatne repozytorium faktur
	string path = Server.MapPath("~/bin/lastInvoice.pdf"); // po stronie serwera, faktura zapisywana jest do pliku lastInvoice.pdf (być może nie jest to ostateczne rozwiązanie)
	string fileName = invoice.CreationDate.Value.ToShortDateString() + "_" + invoice.Customer.CompanyName + ".pdf"; // nazwa pliku otrzymywanego przez użytkownika jest postaci dataWystawienia_nazwaFirmyKlienckiej.pdf

	PdfCreator pdfCreator = new PdfCreator(invoice); // klasa PdfCreator tworzy dokument PDF na podstawie encji faktury z bazy danych
	pdfCreator.Create(invoiceType);
	pdfCreator.SaveDocument(path, false);

	return File(path, "PDF|*.pdf", fileName); // zwrócenie pliku PDF
}

Etykiety
Na koniec funkcjonalność niewidoczna na pokazanym na początku wpisu screenie – etykiety, pojawiające się po wskazaniu kursorem niektórych komórek tabeli zawierającej listę faktur:

  • po wskazaniu daty wystawienia faktury, wyświetlana jest etykieta zawierająca także datę sprzedaży,
  • do każdego produktu przypisana jest etykieta o treści: [ilość] [j.m.] ([wartość brutto] [waluta]), na przykład: 10 kg (100 PLN),
  • etykieta pola Do zapłaty zawiera również wartości pól Zapłacono i Pozostało do zapłaty.

Wygląda na to, że opisałem już wszystkie najważniejsze podstrony aplikacji. Prawdopodobnie zrezygnuję z tworzenia strony głównej, której rolę być może przejmie przedstawiona właśnie Historia faktur. Od teraz praca nad projektem będzie opierać się na testowaniu i szlifowaniu drobnych modułów programu, uzupełnię także informacje na stronie About. Jak widać, piętnaście tygodni to optymalna ilość czasu na stworzenie podobnego projektu i zdobycie sporej ilości wiedzy i doświadczenia.

Reklamy

Filtrowanie danych

Po wprowadzeniu InvoiceInvoker w stadium pre-pre-beta, zająłem się dodawaniem kolejnych modeli, widoków i kontrolerów. Jak na razie projekt uzupełniłem o stronę klientów (bazującą na tabeli RegisteredCustomers bazy danych i analogiczną to strony produktów, więc niestanowiącą wyzwania). Strony faktur i ich szablonów wymagać będą przemyślenia, dlatego zostawiam je na deser. Dziś natomiast przybliżę sposób wyłuskiwania interesujących użytkownika pozycji z nieprzebranego mrowia produktów i niezliczonych szeregów klientów – w skrócie: filtrowania danych.

Filtrowanie udostępniane przez repozytoria
Opisując implementację wzorca repozytorium w warstwie dostępu do danych, zwróciłem uwagę na metodę GetByExpression, pozwalającej na pobranie obiektów spełniających dowolne warunki.:
(tutaj dla repozytorium klientów)

public List<RegisteredCustomer> GetByExpression(Func<RegisteredCustomer, bool> expression)
{
	using (MainDataContext dataContext = new MainDataContext(_connectionString))
	{
		var mainQuery = from customer in dataContext.RegisteredCustomers
						where customer.RegisteredSellerId == _registeredSellerId // metody grupowej selekcji zwracają encje przypisane konkretnemu sprzedawcy
						orderby customer.CompanyName ascending
						select customer;
		return mainQuery.Where(expression).ToList();
	}
}

Postanowiłem udostępnić użytkownikowi filtrowanie klientów po imieniu, nazwisku i nazwie firmy, a produktów po nazwie i cenie netto (przez zdefiniowanie ceny minimalnej i maksymalnej).

Konstrukcja filtrów
Dla filtru produktów stworzyłem klasę przechowującą filtrowane pola i konstruującą z nich wyrażenie lambda, przekazywane wspomnianej przed chwilą metodzie repozytorium:

public class ProductsFilter
{
	[DisplayName("Nazwa")]
	public string Name { get; set; }

	[DisplayName("Min. cena netto")]
	[RegularExpression("^[0-9]+(,[0-9])?[0-9]*$")]
	public string MinPrice { get; set; }

	[DisplayName("Maks. cena netto")]
	[RegularExpression("^[0-9]+(,[0-9])?[0-9]*$")]
	public string MaxPrice { get; set; }

	public Func<RegisteredProduct, bool> GetExpression()
	{
		string name = Name ?? "";
		decimal minPrice;
		decimal maxPrice;

		decimal.TryParse(MinPrice, out minPrice);

		if (decimal.TryParse(MaxPrice, out maxPrice) == false)
			maxPrice = decimal.MaxValue;

		return product => product.Name.ToLower().StartsWith(name.ToLower()) && product.NetPrice >= minPrice && product.NetPrice <= maxPrice;
	}
}

W przypadku filtrowania po nazwie, zgodność początku nazwy produktu z podanym przez użytownika ciągiem znaków (wielkość liter nie ma znaczenia) wystarczy, aby produkt został wyświetlony.
Filtr klientów posiada tylko jedno (dlatego nie utworzyłem jego klasy) kryterium: NameFilter. Akceptuje klienta, jeśli podany przez użytkownika ciąg znaków jest początkiem jego imienia (lub któregoś z imion), nazwiska albo nazwy firmy. Wielkość liter i tutaj nie ma znaczenia. Wyrażenie lambda tego filtru wygląda więc tak:

Func<RegisteredCustomer, bool> expression = customer =>
	customer.CustomerName.ToLower().Split(' ').Any(x => x.StartsWith(NameFilter.ToLower())) ||
	customer.CompanyName.ToLower().StartsWith(NameFilter.ToLower());

(Zaznaczona linia dzieli nazwę (imię i nazwisko) klienta na słowa, w których szuka tych akceptowanych przez filtr.)

Implementacja w kontrolerach
Filtrowanie i wyświetlanie pozycji udostępniają akcje (i widoki) Index stron klientów i produktów. Korzystają one z odpowiednich modeli:
(dla strony klientów)

public class CustomersIndexViewModel
{
	[DisplayName("Imię i nazwisko lub nazwa firmy")]
	public string NameFilter { get; set; } // wspomniane pole filtru klientów
	public List<RegisteredCustomer> Customers { get; set; } // lista klientów
}

(dla strony produktów)

public class ProductsIndexViewModel
{
	public ProductsFilter Filter { get; set; } // filtr produktów
	public List<RegisteredProduct> Products { get; set; } // lista produktów
}

Akcje wyglądają więc tak:
(dla strony klientów)

public ActionResult Index(CustomersIndexViewModel viewModel)
{
	string nameFilter = viewModel.NameFilter ?? "";

	Func<RegisteredCustomer, bool> expression = customer => // pokazane wcześniej zapytanie
		customer.CustomerName.ToLower().Split(' ').Any(x => x.StartsWith(nameFilter.ToLower())) ||
		customer.CompanyName.ToLower().StartsWith(nameFilter.ToLower());

	viewModel.Customers = _repository.GetByExpression(expression); // _repository - prywatne repozytorium klientów

	return View(viewModel);
}

(dla strony produktów)

public ActionResult Index(ProductsIndexViewModel viewModel)
{
	if (viewModel.Filter == null)
		viewModel.Filter = new ProductsFilter();

	viewModel.Products = _repository.GetByExpression(viewModel.Filter.GetExpression()); // _repository - prywatne repozytorim produktów

	return View(viewModel);
}

Implementacja w widokach
Pozostało jeszcze tylko w odpowiednich widokach wyświetlić po kilka pól tekstowych i po dwa przyciski: aktywujące bądź czyszczące filtry. W widoku klientów wygląda to tak:

<% using (Html.BeginForm()) { %>
<fieldset>
    <legend>Filtr</legend>
    <%: Html.LabelFor(model => model.NameFilter) %>: <!-- etykieta pola filtru -->
    <%: Html.TextBoxFor(model => model.NameFilter) %> <!--  pole tekstowe pola filtru -->
    <input type="submit" value="Filtruj" /> | <%: Html.ActionLink("Wyczyść", "Index") %>
</fieldset>
<% } %>

W widoku produktów, tak:

<% using (Html.BeginForm()) { %>
<fieldset>
    <legend>Filtr</legend>
    <%: Html.LabelFor(model => model.Filter.Name) %>: <!-- etykieta pola nazwy -->
    <%: Html.TextBoxFor(model => model.Filter.Name) %> <!-- pole tekstowe nazwy -->
    <%: Html.LabelFor(model => model.Filter.MinPrice) %>: <!-- etykieta pola ceny minimalnej -->
    <%: Html.TextBoxFor(model => model.Filter.MinPrice) %> <!-- pole tekstowe ceny minimalnej -->
    <%: Html.LabelFor(model => model.Filter.MaxPrice) %>: <!-- etykieta pola ceny maksymalnej -->
    <%: Html.TextBoxFor(model => model.Filter.MaxPrice) %> <!-- pole tekstowe ceny maksymalnej -->
    <input type="submit" value="Filtruj" /> | <%: Html.ActionLink("Wyczyść", "Index") %>
</fieldset>
<% } %>

Tym sposobem dałem użytkownikowi możliwość usunięcia sprzed swych szanownych oczu nieinteresujących go pozycji. Za kilka dni dam mu również możliwość definiowania szablonów faktur. Zapraszam!