1454 lines
32 KiB
Go
1454 lines
32 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
rooteway "git.loyso.art/frx/eway"
|
|
"git.loyso.art/frx/eway/cmd/cli/components"
|
|
"git.loyso.art/frx/eway/internal/crypto"
|
|
"git.loyso.art/frx/eway/internal/entity"
|
|
"git.loyso.art/frx/eway/internal/export"
|
|
"git.loyso.art/frx/eway/internal/interconnect/eway"
|
|
"git.loyso.art/frx/eway/internal/matcher"
|
|
|
|
"github.com/rodaine/table"
|
|
"github.com/rs/zerolog"
|
|
"github.com/urfave/cli/v3"
|
|
)
|
|
|
|
type empty entity.Empty
|
|
|
|
func main() {
|
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer func() {
|
|
cancel()
|
|
}()
|
|
err := runcli(ctx)
|
|
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "unable to handle app: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
os.Exit(0)
|
|
}
|
|
|
|
func runcli(ctx context.Context) (err error) {
|
|
app := setupCLI()
|
|
return app.Run(ctx, os.Args)
|
|
}
|
|
|
|
func setupDI() cli.BeforeFunc {
|
|
return func(ctx context.Context, cmd *cli.Command) error {
|
|
if out := cmd.String("output"); out != "" {
|
|
var err error
|
|
cmd.Writer, err = os.Create(out)
|
|
if err != nil {
|
|
return fmt.Errorf("setting writer: %w", err)
|
|
}
|
|
}
|
|
|
|
cfgpath := cmd.String("config")
|
|
debugLevel := cmd.Bool("verbose")
|
|
jsonFormat := cmd.Bool("json")
|
|
|
|
err := components.SetupDI(ctx, cfgpath, debugLevel, jsonFormat)
|
|
if err != nil {
|
|
return fmt.Errorf("setting up di: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func releaseDI() cli.AfterFunc {
|
|
return func(ctx context.Context, c *cli.Command) error {
|
|
if f, ok := c.Writer.(*os.File); ok {
|
|
err := f.Close()
|
|
if err != nil {
|
|
println("unable to close output file:", err.Error())
|
|
}
|
|
}
|
|
|
|
return components.Shutdown()
|
|
}
|
|
}
|
|
|
|
func setupCLI() *cli.Command {
|
|
app := &cli.Command{
|
|
Name: "ewaycli",
|
|
Description: "a cli for running eway logic",
|
|
Version: fmt.Sprintf("%s (%s) %s", rooteway.Version(), rooteway.Commit(), rooteway.BuildTime()),
|
|
Flags: []cli.Flag{
|
|
&cli.StringFlag{
|
|
Name: "config",
|
|
Usage: "path to config in TOML format",
|
|
Value: "config.toml",
|
|
TakesFile: true,
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "verbose",
|
|
Aliases: []string{"d"},
|
|
Usage: "enables verbose logging",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "json",
|
|
Usage: "enables json log format",
|
|
Persistent: true,
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "output",
|
|
Aliases: []string{"o"},
|
|
Usage: "Defines output for commands",
|
|
TakesFile: true,
|
|
},
|
|
},
|
|
|
|
Before: setupDI(),
|
|
After: releaseDI(),
|
|
|
|
Commands: []*cli.Command{
|
|
newAppCmd(),
|
|
newCryptoCmd(),
|
|
newParseCmd(),
|
|
newImportCmd(),
|
|
newExportCmd(),
|
|
newViewCmd(),
|
|
},
|
|
}
|
|
|
|
return app
|
|
}
|
|
|
|
func newCryptoCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "crypto",
|
|
Usage: "methods for encrypt/decrypt various things",
|
|
Commands: []*cli.Command{
|
|
newCryptoEncyptCmd(),
|
|
newCryptoDecryptCmd(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func newCryptoEncyptCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "encrypt",
|
|
Usage: "encypt incoming text",
|
|
Flags: []cli.Flag{
|
|
&cli.StringFlag{
|
|
Name: "text",
|
|
Aliases: []string{"t"},
|
|
Required: true,
|
|
},
|
|
},
|
|
Action: cryptoDeEncryptAction(true),
|
|
}
|
|
}
|
|
|
|
func newAppCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "app",
|
|
Usage: "commands for running different applications",
|
|
Commands: []*cli.Command{
|
|
newAppYmlExporter(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func newAppYmlExporter() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "ymlexporter",
|
|
Usage: "server on given port a http api for accessing yml catalog",
|
|
Flags: []cli.Flag{
|
|
&cli.IntFlag{
|
|
Name: "port",
|
|
Usage: "Port for accepting connections",
|
|
Value: 9448,
|
|
Validator: func(i int64) error {
|
|
if i > 1<<16 || i == 0 {
|
|
return cli.Exit("bad port value, allowed values should not exceed 65535", 1)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "src",
|
|
TakesFile: true,
|
|
Usage: "Source to catalog. Should be a valid xml file",
|
|
Value: "yml_catalog.xml",
|
|
Validator: func(s string) error {
|
|
_, err := os.Stat(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
},
|
|
},
|
|
},
|
|
Action: appYMLExporterAction,
|
|
}
|
|
}
|
|
|
|
func newParseCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "parse",
|
|
Usage: "category for parsing items from various sources",
|
|
Commands: []*cli.Command{
|
|
newParseEwayCmd(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func newParseEwayCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "eway",
|
|
Usage: "parse all available eway goods",
|
|
Commands: []*cli.Command{
|
|
newParseEwayGetCmd(),
|
|
newParseEwayListCmd(),
|
|
newParseEwayDumpCmd(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func newParseEwayGetCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "get",
|
|
Usage: "loads information about the product by parsing product's html page",
|
|
Flags: []cli.Flag{
|
|
&cli.IntFlag{
|
|
Name: "cart",
|
|
Aliases: []string{"c"},
|
|
Usage: "cart of the product",
|
|
Required: true,
|
|
},
|
|
},
|
|
Action: decorateAction(parseEwayGetAction),
|
|
}
|
|
}
|
|
|
|
func newParseEwayListCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "list",
|
|
Usage: "parse all available eway goods",
|
|
Flags: []cli.Flag{
|
|
&cli.IntFlag{
|
|
Name: "page",
|
|
Usage: "choose page to load",
|
|
Value: 1,
|
|
},
|
|
&cli.IntFlag{
|
|
Name: "limit",
|
|
Usage: "limits output",
|
|
Value: 100,
|
|
},
|
|
&cli.IntFlag{
|
|
Name: "min-stock",
|
|
Usage: "filters by minimum available items in stock",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "pretty",
|
|
Usage: "pretty prints output",
|
|
},
|
|
},
|
|
Action: decorateAction(parseEwayListAction),
|
|
}
|
|
}
|
|
|
|
func newParseEwayDumpCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "dump",
|
|
Usage: "dumps content of eway catalog inside db",
|
|
Action: decorateAction(parseEwayDumpAction),
|
|
}
|
|
}
|
|
|
|
func newImportCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "import",
|
|
Usage: "category for importing data from sources",
|
|
Commands: []*cli.Command{
|
|
newImportFromFileCmd(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func newImportFromFileCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "file",
|
|
Usage: "imports from file into db",
|
|
Flags: []cli.Flag{
|
|
&cli.StringFlag{
|
|
Name: "src",
|
|
Value: "",
|
|
Usage: "source of the data.",
|
|
Required: true,
|
|
},
|
|
},
|
|
Action: decorateAction(importFromFileAction),
|
|
}
|
|
}
|
|
|
|
func newExportCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "export",
|
|
Usage: "category for exporting stored data",
|
|
Commands: []*cli.Command{
|
|
newExportYMLCatalogCmd(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func newExportYMLCatalogCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "yml_catalog",
|
|
Usage: "export data into as yml_catalog in xml format",
|
|
Flags: []cli.Flag{
|
|
&cli.StringFlag{
|
|
Name: "out",
|
|
Aliases: []string{"o"},
|
|
Usage: "destination path",
|
|
Value: "yml_catalog.xml",
|
|
TakesFile: true,
|
|
},
|
|
&cli.IntFlag{
|
|
Name: "limit",
|
|
Usage: "limits output offers to required value",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "pretty",
|
|
Usage: "prettify output",
|
|
},
|
|
},
|
|
Action: decorateAction(exportYMLCatalogAction),
|
|
}
|
|
}
|
|
|
|
func newViewCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "view",
|
|
Usage: "Set of commands to view the data inside db",
|
|
Commands: []*cli.Command{
|
|
newViewCategoriesCmd(),
|
|
newViewItemsCmd(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func newViewCategoriesCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "categories",
|
|
Usage: "Set of commands to work with categories",
|
|
Commands: []*cli.Command{
|
|
newViewCategoriesListCmd(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func newViewItemsCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "items",
|
|
Usage: "Set of command to work with items",
|
|
Commands: []*cli.Command{
|
|
newViewItemsGetCmd(),
|
|
newViewItemsCountCmd(),
|
|
newViewItemsUniqueParams(),
|
|
newViewItemsParamsKnownValues(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func newViewItemsUniqueParams() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "unique-params",
|
|
Usage: "Show all stored unique param values",
|
|
Description: "This command iterates over each item and collect keys of params in a dict and then" +
|
|
" print it to the output. It's useful to find all unique parameters",
|
|
Action: decorateAction(viewItemsUniqueParamsAction),
|
|
}
|
|
}
|
|
|
|
func newViewItemsParamsKnownValues() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "params-values",
|
|
Usage: "Show all values of requested parameters",
|
|
Flags: []cli.Flag{
|
|
&cli.BoolFlag{
|
|
Name: "case-insensitive",
|
|
Usage: "Ignores cases of keys",
|
|
},
|
|
&cli.StringSliceFlag{
|
|
Name: "regex",
|
|
Usage: "Registers regex to match",
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "keys-only",
|
|
Usage: "prints only keys",
|
|
},
|
|
},
|
|
Action: decorateAction(viewItemsParamsKnownValuesAction),
|
|
}
|
|
}
|
|
|
|
func newViewItemsCountCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "count",
|
|
Usage: "iterates over collection and counts number of items",
|
|
Flags: []cli.Flag{
|
|
&cli.StringSliceFlag{
|
|
Name: "param-key-match",
|
|
Usage: "filters by parameters with AND logic",
|
|
},
|
|
},
|
|
Action: decorateAction(viewItemsCountAction),
|
|
}
|
|
}
|
|
|
|
func newViewItemsGetCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "get",
|
|
Usage: "gets goods item by its id",
|
|
Flags: []cli.Flag{
|
|
&cli.StringFlag{
|
|
Name: "id",
|
|
Usage: "id of the goods item. Either id or cart-id should be set",
|
|
},
|
|
&cli.IntFlag{
|
|
Name: "cart-id",
|
|
Usage: "cart-id of the item. Either cart-id or id should be set",
|
|
},
|
|
},
|
|
Action: decorateAction(viewItemsGetAction),
|
|
}
|
|
}
|
|
|
|
func newViewCategoriesListCmd() *cli.Command {
|
|
return &cli.Command{
|
|
Name: "list",
|
|
Usage: "lists stored categories stored in database",
|
|
Flags: []cli.Flag{
|
|
&cli.IntFlag{
|
|
Name: "limit",
|
|
Usage: "limits output to selected items",
|
|
Value: 20,
|
|
},
|
|
&cli.IntFlag{
|
|
Name: "page",
|
|
Usage: "in case of limit, selects page",
|
|
Value: 0,
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "with-total",
|
|
Usage: "prints total count of categories",
|
|
},
|
|
},
|
|
Action: decorateAction(viewCategoriesListAction),
|
|
}
|
|
}
|
|
|
|
func viewItemsGetAction(ctx context.Context, c *cli.Command) error {
|
|
r, err := components.GetRepository()
|
|
if err != nil {
|
|
return fmt.Errorf("getting repository: %w", err)
|
|
}
|
|
|
|
id := c.String("id")
|
|
cartID := c.Int("cart-id")
|
|
if id == "" && cartID == 0 {
|
|
return cli.Exit("oneof: id or cart-id should be set", 1)
|
|
} else if id != "" && cartID != 0 {
|
|
return cli.Exit("oneof: id or cart-id should be set", 1)
|
|
}
|
|
|
|
var item entity.GoodsItem
|
|
if id != "" {
|
|
item, err = r.GoodsItem().Get(ctx, id)
|
|
} else {
|
|
item, err = r.GoodsItem().GetByCart(ctx, cartID)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("getting item: %w", err)
|
|
}
|
|
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
|
|
err = enc.Encode(item)
|
|
if err != nil {
|
|
return fmt.Errorf("encoding item: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func viewItemsUniqueParamsAction(ctx context.Context, c *cli.Command) error {
|
|
repository, err := components.GetRepository()
|
|
if err != nil {
|
|
return fmt.Errorf("getting repository: %w", err)
|
|
}
|
|
|
|
knownParams := map[string]empty{}
|
|
iter, err := repository.GoodsItem().ListIter(ctx, 1)
|
|
if err != nil {
|
|
return fmt.Errorf("getting list iter: %w", err)
|
|
}
|
|
for item := range iter {
|
|
for k := range item.Parameters {
|
|
knownParams[k] = empty{}
|
|
}
|
|
}
|
|
|
|
bw := bufio.NewWriter(c.Writer)
|
|
for paramName := range knownParams {
|
|
_, err = bw.WriteString(paramName + "\n")
|
|
if err != nil {
|
|
return fmt.Errorf("unable to write: %w", err)
|
|
}
|
|
}
|
|
|
|
return bw.Flush()
|
|
}
|
|
|
|
type chanIter[T any] struct {
|
|
in <-chan T
|
|
err error
|
|
next T
|
|
}
|
|
|
|
func (i *chanIter[T]) Next() (ok bool) {
|
|
if i.err != nil {
|
|
return false
|
|
}
|
|
|
|
i.next, ok = <-i.in
|
|
if !ok {
|
|
i.err = errors.New("channel closed")
|
|
}
|
|
|
|
return ok
|
|
}
|
|
|
|
func (i *chanIter[T]) Get() T {
|
|
return i.next
|
|
}
|
|
|
|
func (i *chanIter[T]) Err() error {
|
|
return i.err
|
|
}
|
|
|
|
func (i *chanIter[T]) Close() {
|
|
for range i.in {
|
|
}
|
|
}
|
|
|
|
func getItemsIter(ctx context.Context, r entity.GoodsItemRepository) *chanIter[entity.GoodsItem] {
|
|
in, err := r.ListIter(ctx, 3)
|
|
|
|
return &chanIter[entity.GoodsItem]{
|
|
in: in,
|
|
err: err,
|
|
}
|
|
}
|
|
|
|
func viewItemsParamsKnownValuesAction(ctx context.Context, c *cli.Command) error {
|
|
repository, err := components.GetRepository()
|
|
if err != nil {
|
|
return fmt.Errorf("getting repository: %w", err)
|
|
}
|
|
log, err := components.GetLogger()
|
|
if err != nil {
|
|
return fmt.Errorf("getting logger: %w", err)
|
|
}
|
|
|
|
params := c.Args().Slice()
|
|
opts := make([]matcher.RadixOpt, 0, 1)
|
|
if c.Bool("case-insensitive") {
|
|
opts = append(opts, matcher.RadixCaseInsensitive())
|
|
}
|
|
|
|
m := matcher.NewRadix(opts...)
|
|
for _, param := range params {
|
|
log.Debug().Str("param", param).Msg("registering param")
|
|
m.Register(param)
|
|
}
|
|
for _, regexp := range c.StringSlice("regex") {
|
|
log.Debug().Str("regexp", regexp).Msg("registering regexp")
|
|
m.RegisterRegexp(regexp)
|
|
}
|
|
|
|
requestedValues := make(map[string]map[string]empty, len(params))
|
|
requestedValuesByPattern := make(map[string]map[string]empty, len(params))
|
|
iter := getItemsIter(ctx, repository.GoodsItem())
|
|
for iter.Next() {
|
|
item := iter.Get()
|
|
for k, v := range item.Parameters {
|
|
matchedPattern := m.MatchByPattern(k)
|
|
if matchedPattern == "" {
|
|
continue
|
|
}
|
|
|
|
values, ok := requestedValues[k]
|
|
if !ok {
|
|
values = make(map[string]empty)
|
|
}
|
|
values[v] = empty{}
|
|
requestedValues[k] = values
|
|
|
|
values, ok = requestedValuesByPattern[matchedPattern]
|
|
if !ok {
|
|
values = map[string]empty{}
|
|
}
|
|
values[v] = empty{}
|
|
requestedValuesByPattern[matchedPattern] = values
|
|
}
|
|
}
|
|
|
|
bw := bufio.NewWriter(c.Writer)
|
|
_, _ = bw.WriteString("Matches:\n")
|
|
|
|
if c.Bool("keys-only") {
|
|
for k := range requestedValues {
|
|
_, _ = bw.WriteString(k + "\n")
|
|
}
|
|
} else {
|
|
for k, v := range requestedValues {
|
|
_, _ = bw.WriteString(k + ": ")
|
|
values := make([]string, 0, len(v))
|
|
for item := range v {
|
|
values = append(values, strconv.Quote(item))
|
|
}
|
|
valuesStr := "[" + strings.Join(values, ",") + "]\n"
|
|
_, _ = bw.WriteString(valuesStr)
|
|
}
|
|
}
|
|
|
|
_, _ = bw.WriteString("\nPatterns:\n")
|
|
for _, pattern := range m.Patterns() {
|
|
_, _ = bw.WriteString(pattern + "\n")
|
|
}
|
|
|
|
return bw.Flush()
|
|
}
|
|
|
|
func viewItemsCountAction(ctx context.Context, c *cli.Command) error {
|
|
r, err := components.GetRepository()
|
|
if err != nil {
|
|
return fmt.Errorf("getting repository: %w", err)
|
|
}
|
|
|
|
filters := c.StringSlice("param-key-match")
|
|
m := matcher.NewRadix()
|
|
patternMapped := make(map[string]empty, len(filters))
|
|
if len(filters) == 0 {
|
|
m.Register("*")
|
|
} else {
|
|
for _, f := range filters {
|
|
m.Register(f)
|
|
}
|
|
for _, pattern := range m.Patterns() {
|
|
patternMapped[pattern] = empty{}
|
|
}
|
|
}
|
|
|
|
var count int
|
|
items, err := r.GoodsItem().List(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("getting items: %w", err)
|
|
}
|
|
for _, item := range items {
|
|
seenPatterns := map[string]empty{}
|
|
|
|
for k := range item.Parameters {
|
|
pattern := m.MatchByPattern(k)
|
|
if pattern == "" {
|
|
continue
|
|
}
|
|
if _, ok := seenPatterns[pattern]; ok {
|
|
continue
|
|
}
|
|
seenPatterns[pattern] = empty{}
|
|
}
|
|
|
|
if len(seenPatterns) == len(patternMapped) {
|
|
println("Item matched", item.Articul, item.Cart)
|
|
count++
|
|
}
|
|
}
|
|
|
|
println(count)
|
|
|
|
return nil
|
|
}
|
|
|
|
func viewCategoriesListAction(ctx context.Context, c *cli.Command) error {
|
|
r, err := components.GetRepository()
|
|
if err != nil {
|
|
return fmt.Errorf("getting repository: %w", err)
|
|
}
|
|
|
|
categories, err := r.Category().List(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("listing categories: %w", err)
|
|
}
|
|
|
|
limit := int(c.Int("limit"))
|
|
page := int(c.Int("page"))
|
|
total := len(categories)
|
|
|
|
if page == 0 {
|
|
page = 1
|
|
}
|
|
|
|
if limit > 0 {
|
|
offset := (page - 1) * limit
|
|
if offset > len(categories) {
|
|
offset = len(categories) - 1
|
|
}
|
|
|
|
limit = offset + limit
|
|
if limit > len(categories) {
|
|
limit = len(categories)
|
|
}
|
|
|
|
categories = categories[offset:limit]
|
|
}
|
|
|
|
tbl := table.New("ID", "Name")
|
|
for _, category := range categories {
|
|
if category.ID == 0 && category.Name == "" {
|
|
continue
|
|
}
|
|
tbl.AddRow(category.ID, category.Name)
|
|
}
|
|
|
|
tbl.Print()
|
|
|
|
if c.Bool("with-total") {
|
|
zerolog.Ctx(ctx).Info().Int("count", total).Msg("total categories stats")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func importFromFileAction(ctx context.Context, c *cli.Command) error {
|
|
const maxBatch = 2000
|
|
|
|
r, err := components.GetRepository()
|
|
if err != nil {
|
|
return fmt.Errorf("getting repository: %w", err)
|
|
}
|
|
|
|
filesrc := c.String("src")
|
|
log := zerolog.Ctx(ctx)
|
|
|
|
log.Debug().Str("filepath", filesrc).Msg("importing data from file")
|
|
|
|
productsFile, err := os.Open(filesrc)
|
|
if err != nil {
|
|
return fmt.Errorf("opening file: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
errClose := productsFile.Close()
|
|
if errClose != nil {
|
|
log.Warn().Err(errClose).Msg("unable to close file")
|
|
}
|
|
}()
|
|
|
|
failedItems, err := os.Create("failed.json")
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("unable to open file for failed results")
|
|
failedItems = os.Stdout
|
|
}
|
|
defer func() {
|
|
if failedItems == os.Stdout {
|
|
return
|
|
}
|
|
|
|
errClose := failedItems.Close()
|
|
log.Err(errClose).Msg("closing file")
|
|
}()
|
|
|
|
var (
|
|
goodsItem entity.GoodsItem
|
|
goodsItems []entity.GoodsItem
|
|
failedToInsert int
|
|
)
|
|
|
|
seenCategories := make(map[string]empty)
|
|
categories, err := r.Category().List(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("listing categories: %w", err)
|
|
}
|
|
|
|
for _, category := range categories {
|
|
seenCategories[category.Name] = empty{}
|
|
}
|
|
|
|
bfile := bufio.NewReader(productsFile)
|
|
for {
|
|
line, _, err := bfile.ReadLine()
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
|
|
}
|
|
return fmt.Errorf("reading line: %w", err)
|
|
}
|
|
|
|
err = json.Unmarshal(line, &goodsItem)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("line", string(line)).Msg("unable to unmarshal line into item")
|
|
_, _ = failedItems.Write(line)
|
|
_, _ = failedItems.Write([]byte{'\n'})
|
|
failedToInsert++
|
|
continue
|
|
}
|
|
|
|
goodsItems = append(goodsItems, goodsItem)
|
|
|
|
if _, ok := seenCategories[goodsItem.Type]; ok {
|
|
continue
|
|
}
|
|
|
|
if goodsItem.Type == "" {
|
|
log.Warn().Msg("bad item without proper type")
|
|
_ = json.NewEncoder(failedItems).Encode(goodsItem)
|
|
continue
|
|
}
|
|
|
|
_, err = r.Category().Create(ctx, goodsItem.Type)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to create new category: %w", err)
|
|
}
|
|
log.Debug().Any("category", goodsItem.Type).Msg("inserted new category")
|
|
seenCategories[goodsItem.Type] = empty{}
|
|
}
|
|
|
|
log.Debug().Int("count", len(goodsItems)).Int("failed", failedToInsert).Msg("preparing to upload")
|
|
|
|
start := time.Now()
|
|
batchSize := int(maxBatch)
|
|
for i := 0; i < len(goodsItems); i += batchSize {
|
|
to := i + batchSize
|
|
if to > len(goodsItems) {
|
|
to = len(goodsItems)
|
|
}
|
|
|
|
_, err = r.GoodsItem().UpsertMany(ctx, goodsItems[i:to]...)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting items: %w", err)
|
|
}
|
|
log.Debug().Int("count", to-i).Msg("inserted batch")
|
|
}
|
|
log.Debug().Dur("elapsed", time.Since(start)).Msg("upload finished")
|
|
|
|
return nil
|
|
}
|
|
|
|
type action func(ctx context.Context, c *cli.Command) error
|
|
|
|
func decorateAction(a action) cli.ActionFunc {
|
|
return func(ctx context.Context, c *cli.Command) error {
|
|
var data [3]byte
|
|
_, _ = rand.Read(data[:])
|
|
reqid := hex.EncodeToString(data[:])
|
|
|
|
log, err := components.GetLogger()
|
|
if err != nil {
|
|
return fmt.Errorf("getting logger: %w", err)
|
|
}
|
|
|
|
log = log.With().Str("reqid", reqid).Logger()
|
|
ctx = log.WithContext(ctx)
|
|
|
|
start := time.Now()
|
|
defer func() {
|
|
log.Info().Float64("elapsed", time.Since(start).Seconds()).Msg("command completed")
|
|
}()
|
|
|
|
log.Info().Msg("command execution started")
|
|
return a(ctx, c)
|
|
}
|
|
}
|
|
|
|
func exportYMLCatalogAction(ctx context.Context, c *cli.Command) error {
|
|
path := c.String("out")
|
|
limit := c.Int("limit")
|
|
pretty := c.Bool("pretty")
|
|
|
|
log, err := components.GetLogger()
|
|
if err != nil {
|
|
return fmt.Errorf("getting logger: %w", err)
|
|
}
|
|
|
|
r, err := components.GetRepository()
|
|
if err != nil {
|
|
return fmt.Errorf("getting repository: %w", err)
|
|
}
|
|
|
|
categories, err := r.Category().List(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("listing categories: %w", err)
|
|
}
|
|
|
|
shop := export.Shop{
|
|
Currencies: []export.Currency{{
|
|
ID: "RUR",
|
|
Rate: 1,
|
|
}},
|
|
Categories: make([]export.Category, 0, len(categories)),
|
|
}
|
|
|
|
categoryByNameIdx := make(map[string]int64, len(categories))
|
|
for _, category := range categories {
|
|
categoryByNameIdx[category.Name] = category.ID
|
|
shop.Categories = append(shop.Categories, export.Category{
|
|
ID: category.ID,
|
|
Name: category.Name,
|
|
})
|
|
}
|
|
|
|
itemsIter, err := r.GoodsItem().ListIter(ctx, 1)
|
|
if err != nil {
|
|
return fmt.Errorf("getting items iterator: %w", err)
|
|
}
|
|
|
|
for item := range itemsIter {
|
|
offer := goodsItemAsOffer(item, categoryByNameIdx)
|
|
shop.Offers = append(shop.Offers, offer)
|
|
}
|
|
if err = ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return fmt.Errorf("creating file: %w", err)
|
|
}
|
|
defer func() {
|
|
errClose := f.Close()
|
|
if err == nil {
|
|
err = errClose
|
|
return
|
|
}
|
|
|
|
log.Err(errClose).Msg("file closed or not")
|
|
}()
|
|
|
|
if limit > 0 {
|
|
shop.Offers = shop.Offers[:limit]
|
|
}
|
|
|
|
container := export.YmlContainer{
|
|
YmlCatalog: export.YmlCatalog{
|
|
Shop: shop,
|
|
Date: time.Now(),
|
|
},
|
|
}
|
|
|
|
_, err = f.Write([]byte(xml.Header))
|
|
if err != nil {
|
|
return fmt.Errorf("writing header: %w", err)
|
|
}
|
|
_, err = f.Write([]byte("<!DOCTYPE yml_catalog SYSTEM \"shops.dtd\">\n"))
|
|
enc := xml.NewEncoder(f)
|
|
if pretty {
|
|
enc.Indent("", " ")
|
|
}
|
|
return enc.Encode(container)
|
|
}
|
|
|
|
func parseEwayGetAction(ctx context.Context, cmd *cli.Command) error {
|
|
cartID := cmd.Int("cart")
|
|
|
|
client, err := components.GetEwayClient()
|
|
if err != nil {
|
|
return fmt.Errorf("getting eway client: %w", err)
|
|
}
|
|
|
|
pi, err := client.GetProductInfo(ctx, cartID)
|
|
if err != nil {
|
|
return fmt.Errorf("getting product info: %w", err)
|
|
}
|
|
|
|
enc := json.NewEncoder(cmd.Writer)
|
|
enc.SetIndent("", " ")
|
|
err = enc.Encode(pi)
|
|
if err != nil {
|
|
return fmt.Errorf("encoding data: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseEwayListAction(ctx context.Context, cmd *cli.Command) error {
|
|
page := cmd.Int("page")
|
|
limit := cmd.Int("limit")
|
|
atLeast := cmd.Int("min-stock")
|
|
searchInStocks := atLeast > 0
|
|
|
|
client, err := components.GetEwayClient()
|
|
if err != nil {
|
|
return fmt.Errorf("getting eway client: %w", err)
|
|
}
|
|
|
|
log, err := components.GetLogger()
|
|
if err != nil {
|
|
return fmt.Errorf("getting logger: %w", err)
|
|
}
|
|
|
|
start := (page - 1) * limit
|
|
|
|
items, total, err := client.GetGoodsNew(ctx, eway.GetGoodsNewParams{
|
|
Draw: 1,
|
|
Start: int(start),
|
|
Length: int(limit),
|
|
SearchInStocks: searchInStocks,
|
|
RemmantsAtleast: int(atLeast),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("getting new goods: %w", err)
|
|
}
|
|
|
|
productIDs := make([]int, 0, len(items))
|
|
for _, item := range items {
|
|
productIDs = append(productIDs, int(item.Cart))
|
|
}
|
|
|
|
remnants, err := client.GetGoodsRemnants(ctx, productIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("getting remnants: %w", err)
|
|
}
|
|
|
|
goodsItems := make([]entity.GoodsItem, 0, len(items))
|
|
for _, item := range items {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
|
|
pi, err := client.GetProductInfo(ctx, int64(item.Cart))
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("unable to get product info")
|
|
}
|
|
outGood, err := entity.MakeGoodsItem(item, remnants, pi)
|
|
if err != nil {
|
|
return fmt.Errorf("making goods item: %w", err)
|
|
}
|
|
|
|
goodsItems = append(goodsItems, outGood)
|
|
|
|
}
|
|
|
|
var stats = struct {
|
|
Handled int `json:"handled"`
|
|
Loaded int `json:"loaded"`
|
|
Total int `json:"total"`
|
|
}{
|
|
Handled: len(goodsItems),
|
|
Loaded: len(items),
|
|
Total: total,
|
|
}
|
|
if cmd.Bool("json") {
|
|
enc := json.NewEncoder(cmd.Writer)
|
|
if cmd.Bool("pretty") {
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(goodsItems)
|
|
}
|
|
|
|
enc.SetIndent("", "")
|
|
_ = enc.Encode(stats)
|
|
} else {
|
|
tbl := table.
|
|
New("sku", "category", "cart", "stock", "price", "parameters").
|
|
WithWriter(cmd.Writer)
|
|
|
|
for _, outGood := range goodsItems {
|
|
parameters, _ := json.MarshalIndent(outGood.Parameters, "", " ")
|
|
|
|
tbl.AddRow(
|
|
outGood.Articul,
|
|
outGood.Type,
|
|
outGood.Cart,
|
|
outGood.Stock,
|
|
outGood.Price,
|
|
string(parameters),
|
|
)
|
|
}
|
|
|
|
tbl.Print()
|
|
|
|
table.
|
|
New("handled", "loaded", "total").
|
|
WithWriter(cmd.Writer).
|
|
AddRow(stats.Handled, stats.Loaded, stats.Total).
|
|
Print()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
|
|
client, err := components.GetEwayClient()
|
|
if err != nil {
|
|
return fmt.Errorf("getting eway client: %w", err)
|
|
}
|
|
|
|
repository, err := components.GetRepository()
|
|
if err != nil {
|
|
return fmt.Errorf("getting repository: %w", err)
|
|
}
|
|
|
|
logger, err := components.GetLogger()
|
|
if err != nil {
|
|
return fmt.Errorf("getting logger: %w", err)
|
|
}
|
|
|
|
const batchSize = 100
|
|
var i int
|
|
var start int
|
|
|
|
seenItems, err := entity.IterIntoMap[string, entity.GoodsItem](repository.GoodsItem().List(ctx)).Map(func(gi entity.GoodsItem) (string, error) {
|
|
return gi.Articul, nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("making seen items map: %w", err)
|
|
}
|
|
|
|
goodsItems := make([]entity.GoodsItem, 0, batchSize)
|
|
productIDs := make([]int, 0, batchSize)
|
|
|
|
knownCategories := make(map[string]empty)
|
|
err = entity.IterWithErr(repository.Category().List(ctx)).Do(func(c entity.Category) error {
|
|
knownCategories[c.Name] = empty{}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("filling known categories: %w", err)
|
|
}
|
|
|
|
itemsUpdated := make(map[string]empty, len(seenItems))
|
|
stats := struct {
|
|
fetchedInfo int
|
|
handledAll int
|
|
cachedInfo int
|
|
skippedItem int
|
|
}{}
|
|
|
|
dimensionDispatcher := makeDefaultDimensionDispatcher()
|
|
|
|
startFrom := time.Now()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
items, total, err := client.GetGoodsNew(ctx, eway.GetGoodsNewParams{
|
|
Draw: i,
|
|
Start: start,
|
|
Length: batchSize,
|
|
SearchInStocks: true,
|
|
RemmantsAtleast: 5,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("getting next goods batch: %w", err)
|
|
}
|
|
|
|
productIDs = productIDs[:0]
|
|
for _, item := range items {
|
|
productIDs = append(productIDs, int(item.Cart))
|
|
}
|
|
|
|
remnants, err := client.GetGoodsRemnants(ctx, productIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("getting goods remnants: %w", err)
|
|
}
|
|
|
|
goodsItems = goodsItems[:0]
|
|
for _, item := range items {
|
|
var pi entity.GoodsItemInfo
|
|
seenItem := seenItems[item.SKU]
|
|
if time.Since(seenItem.CreatedAt) < time.Hour*24 {
|
|
logger.Debug().Str("sku", item.SKU).Msg("skipping item because it's too fresh")
|
|
stats.skippedItem++
|
|
itemsUpdated[item.SKU] = empty{}
|
|
|
|
continue
|
|
}
|
|
|
|
if len(seenItem.Parameters) != 0 && len(seenItem.PhotoURLs) != 0 {
|
|
pi.Parameters = seenItem.Parameters
|
|
pi.PhotoURLs = seenItem.PhotoURLs
|
|
stats.cachedInfo++
|
|
} else {
|
|
pi, err = client.GetProductInfo(ctx, int64(item.Cart))
|
|
if err != nil {
|
|
return fmt.Errorf("getting product info: %w", err)
|
|
}
|
|
stats.fetchedInfo++
|
|
}
|
|
|
|
goodsItem, err := entity.MakeGoodsItem(item, remnants, pi)
|
|
if err != nil {
|
|
logger.Warn().Err(err).Any("item", item).Msg("unable to make goods item")
|
|
continue
|
|
}
|
|
|
|
for key, value := range goodsItem.Parameters {
|
|
dimensionDispatcher.dispatch(ctx, key, value, &goodsItem.Sizes)
|
|
}
|
|
|
|
itemsUpdated[goodsItem.Articul] = empty{}
|
|
stats.handledAll++
|
|
|
|
goodsItems = append(goodsItems, goodsItem)
|
|
if goodsItem.Type == "" {
|
|
continue
|
|
}
|
|
|
|
if _, ok := knownCategories[goodsItem.Type]; ok {
|
|
continue
|
|
}
|
|
|
|
category, err := repository.Category().Create(ctx, goodsItem.Type)
|
|
if err != nil {
|
|
return fmt.Errorf("creating category: %w", err)
|
|
}
|
|
|
|
logger.Debug().
|
|
Str("name", category.Name).
|
|
Int64("id", category.ID).
|
|
Msg("created new category")
|
|
|
|
knownCategories[goodsItem.Type] = empty{}
|
|
}
|
|
|
|
_, err = repository.GoodsItem().UpsertMany(ctx, goodsItems...)
|
|
if err != nil {
|
|
return fmt.Errorf("upserting items: %w", err)
|
|
}
|
|
|
|
progressFloat := float64(start) / float64(total)
|
|
progress := big.NewFloat(progressFloat).Text('f', 3)
|
|
|
|
elapsed := time.Since(startFrom).Seconds()
|
|
var left int
|
|
if progressFloat != 0 {
|
|
left = int(((1 - progressFloat) / progressFloat) * elapsed)
|
|
}
|
|
|
|
logger.Debug().
|
|
Int("from", start).
|
|
Int("to", start+batchSize).
|
|
Int("total", total).
|
|
Str("progress", progress).
|
|
Int("seconds_left", left).
|
|
Msg("handled next batch items")
|
|
|
|
if len(items) < batchSize {
|
|
break
|
|
}
|
|
|
|
start += batchSize
|
|
i++
|
|
}
|
|
|
|
for k := range itemsUpdated {
|
|
delete(seenItems, k)
|
|
}
|
|
|
|
logger.Info().
|
|
Int("handled", stats.handledAll).
|
|
Int("cached", stats.cachedInfo).
|
|
Int("fetched", stats.fetchedInfo).
|
|
Int("skipped", stats.skippedItem).
|
|
Int("to_delete", len(seenItems)).
|
|
Msg("processed items")
|
|
|
|
for k := range seenItems {
|
|
_, err := repository.GoodsItem().Delete(ctx, k)
|
|
if err != nil {
|
|
logger.Warn().Err(err).Str("sku", k).Msg("unable to delete item")
|
|
continue
|
|
}
|
|
|
|
logger.Debug().Str("sku", k).Msg("deleted item")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func goodsItemAsOffer(in entity.GoodsItem, categoryIDByName map[string]int64) (out export.Offer) {
|
|
const defaultType = "vendor.model"
|
|
const defaultCurrency = "RUR"
|
|
const defaultAvailable = true
|
|
const quantityParamName = "Количество на складе «Москва»"
|
|
const basePictureURL = "https://eway.elevel.ru"
|
|
|
|
imgurl := func(path string) string {
|
|
return basePictureURL + path
|
|
}
|
|
|
|
categoryID := categoryIDByName[in.Type]
|
|
|
|
pictureURLs := make([]string, 0, len(in.PhotoURLs))
|
|
for _, url := range in.PhotoURLs {
|
|
pictureURLs = append(pictureURLs, imgurl(url))
|
|
}
|
|
params := make([]export.Param, len(in.Parameters))
|
|
for k, v := range in.Parameters {
|
|
params = append(params, export.Param{
|
|
Name: k,
|
|
Value: v,
|
|
})
|
|
}
|
|
params = append(params, export.Param{
|
|
Name: quantityParamName,
|
|
Value: strconv.Itoa(in.Stock),
|
|
})
|
|
out = export.Offer{
|
|
ID: in.Cart,
|
|
VendorCode: in.Articul,
|
|
Price: int(in.TariffPrice),
|
|
CategoryID: categoryID,
|
|
PictureURLs: pictureURLs,
|
|
|
|
Model: in.Name,
|
|
Vendor: in.Producer,
|
|
TypePrefix: in.Name,
|
|
Description: in.Description,
|
|
ManufacturerWarrany: true,
|
|
Params: params,
|
|
|
|
Type: defaultType,
|
|
CurrencyID: defaultCurrency,
|
|
Available: defaultAvailable,
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func appYMLExporterAction(ctx context.Context, cmd *cli.Command) error {
|
|
port := cmd.Int("port")
|
|
src := cmd.String("src")
|
|
|
|
log, err := components.GetLogger()
|
|
if err != nil {
|
|
return fmt.Errorf("getting logger: %w", err)
|
|
}
|
|
|
|
mw := func(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
remoteAddr := r.RemoteAddr
|
|
xff := r.Header.Get("x-forwarded-for")
|
|
method := r.Method
|
|
ua := r.UserAgent()
|
|
xreqid := r.Header.Get("x-request-id")
|
|
if xreqid == "" {
|
|
const reqsize = 4
|
|
var reqidRaw [reqsize]byte
|
|
_, _ = rand.Read(reqidRaw[:])
|
|
value := binary.BigEndian.Uint32(reqidRaw[:])
|
|
xreqid = fmt.Sprintf("%x", value)
|
|
w.Header().Set("x-request-id", xreqid)
|
|
}
|
|
|
|
start := time.Now()
|
|
xlog := log.With().Str("request_id", xreqid).Logger()
|
|
xlog.Debug().
|
|
Str("path", path).
|
|
Str("remote_addr", remoteAddr).
|
|
Str("xff", xff).
|
|
Str("method", method).
|
|
Str("user_agent", ua).
|
|
Msg("incoming request")
|
|
|
|
xctx := xlog.WithContext(r.Context())
|
|
r = r.WithContext(xctx)
|
|
next(w, r)
|
|
|
|
elapsed := time.Since(start).Truncate(time.Millisecond).Seconds()
|
|
xlog.Info().
|
|
Float64("elapsed", elapsed).
|
|
Msg("request completed")
|
|
}
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/yml_catalog.xml", mw(func(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, src)
|
|
}))
|
|
|
|
srv := &http.Server{
|
|
Addr: net.JoinHostPort("0.0.0.0", strconv.Itoa(int(port))),
|
|
Handler: mux,
|
|
}
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
|
|
sdctx, sdcancel := context.WithTimeout(context.Background(), time.Second*5)
|
|
defer sdcancel()
|
|
|
|
errShutdown := srv.Shutdown(sdctx)
|
|
if errShutdown != nil {
|
|
log.Warn().Err(errShutdown).Msg("unable to shutdown server")
|
|
}
|
|
}()
|
|
|
|
log.Info().Str("listen_addr", srv.Addr).Msg("running server")
|
|
|
|
err = srv.ListenAndServe()
|
|
if err != nil && errors.Is(err, http.ErrServerClosed) {
|
|
return fmt.Errorf("serving http api: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func cryptoDeEncryptAction(encrypt bool) cli.ActionFunc {
|
|
return func(ctx context.Context, c *cli.Command) (err error) {
|
|
value := c.String("text")
|
|
var out string
|
|
if encrypt {
|
|
out, err = crypto.Encrypt(value)
|
|
} else {
|
|
out, err = crypto.Decrypt(value)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = c.Writer.Write([]byte(out))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = c.Writer.Write([]byte{'\n'})
|
|
return err
|
|
}
|
|
}
|